@cortexkit/aft-bridge 0.60.0 → 0.62.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 (99) hide show
  1. package/dist/bash-format.d.ts +5 -0
  2. package/dist/bash-format.d.ts.map +1 -1
  3. package/dist/bash-format.js +12 -5
  4. package/dist/bash-format.js.map +1 -1
  5. package/dist/bash-hints.d.ts.map +1 -1
  6. package/dist/bash-hints.js +3 -9
  7. package/dist/bash-hints.js.map +1 -1
  8. package/dist/bash-host-fallback.d.ts +1 -1
  9. package/dist/bash-host-fallback.d.ts.map +1 -1
  10. package/dist/bash-host-fallback.js +1 -1
  11. package/dist/bash-host-fallback.js.map +1 -1
  12. package/dist/bridge.d.ts +54 -9
  13. package/dist/bridge.d.ts.map +1 -1
  14. package/dist/bridge.js +210 -106
  15. package/dist/bridge.js.map +1 -1
  16. package/dist/coerce.d.ts +4 -4
  17. package/dist/coerce.d.ts.map +1 -1
  18. package/dist/coerce.js +10 -7
  19. package/dist/coerce.js.map +1 -1
  20. package/dist/config-watch.d.ts +1 -0
  21. package/dist/config-watch.d.ts.map +1 -1
  22. package/dist/config-watch.js +8 -0
  23. package/dist/config-watch.js.map +1 -1
  24. package/dist/downloader.js +4 -3
  25. package/dist/downloader.js.map +1 -1
  26. package/dist/error-contract.d.ts +26 -4
  27. package/dist/error-contract.d.ts.map +1 -1
  28. package/dist/error-contract.js +135 -10
  29. package/dist/error-contract.js.map +1 -1
  30. package/dist/feature-config.d.ts +3 -0
  31. package/dist/feature-config.d.ts.map +1 -1
  32. package/dist/feature-config.js +49 -0
  33. package/dist/feature-config.js.map +1 -1
  34. package/dist/index.d.ts +7 -3
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +7 -2
  37. package/dist/index.js.map +1 -1
  38. package/dist/lsp-github-install.d.ts +104 -0
  39. package/dist/lsp-github-install.d.ts.map +1 -0
  40. package/dist/lsp-github-install.js +1455 -0
  41. package/dist/lsp-github-install.js.map +1 -0
  42. package/dist/lsp-github-probe.d.ts +81 -0
  43. package/dist/lsp-github-probe.d.ts.map +1 -0
  44. package/dist/lsp-github-probe.js +131 -0
  45. package/dist/lsp-github-probe.js.map +1 -0
  46. package/dist/lsp-github-table.d.ts +64 -0
  47. package/dist/lsp-github-table.d.ts.map +1 -0
  48. package/dist/lsp-github-table.js +174 -0
  49. package/dist/lsp-github-table.js.map +1 -0
  50. package/dist/migration.d.ts +10 -3
  51. package/dist/migration.d.ts.map +1 -1
  52. package/dist/migration.js +19 -14
  53. package/dist/migration.js.map +1 -1
  54. package/dist/onnx-runtime.d.ts +2 -0
  55. package/dist/onnx-runtime.d.ts.map +1 -1
  56. package/dist/onnx-runtime.js +12 -7
  57. package/dist/onnx-runtime.js.map +1 -1
  58. package/dist/path-aliases.d.ts +1 -1
  59. package/dist/path-aliases.d.ts.map +1 -1
  60. package/dist/path-aliases.js +63 -39
  61. package/dist/path-aliases.js.map +1 -1
  62. package/dist/pool.d.ts +6 -0
  63. package/dist/pool.d.ts.map +1 -1
  64. package/dist/pool.js +27 -7
  65. package/dist/pool.js.map +1 -1
  66. package/dist/project-identity.d.ts +2 -0
  67. package/dist/project-identity.d.ts.map +1 -1
  68. package/dist/project-identity.js +8 -2
  69. package/dist/project-identity.js.map +1 -1
  70. package/dist/protocol.d.ts +3 -0
  71. package/dist/protocol.d.ts.map +1 -1
  72. package/dist/protocol.js +14 -1
  73. package/dist/protocol.js.map +1 -1
  74. package/dist/resolver.d.ts.map +1 -1
  75. package/dist/resolver.js +5 -2
  76. package/dist/resolver.js.map +1 -1
  77. package/dist/revision-reads.d.ts +25 -0
  78. package/dist/revision-reads.d.ts.map +1 -0
  79. package/dist/revision-reads.js +31 -0
  80. package/dist/revision-reads.js.map +1 -0
  81. package/dist/revivable-transport.d.ts +12 -1
  82. package/dist/revivable-transport.d.ts.map +1 -1
  83. package/dist/revivable-transport.js +68 -7
  84. package/dist/revivable-transport.js.map +1 -1
  85. package/dist/subc-transport.d.ts +8 -0
  86. package/dist/subc-transport.d.ts.map +1 -1
  87. package/dist/subc-transport.js +275 -58
  88. package/dist/subc-transport.js.map +1 -1
  89. package/dist/tar-executable.d.ts +3 -1
  90. package/dist/tar-executable.d.ts.map +1 -1
  91. package/dist/tar-executable.js +35 -15
  92. package/dist/tar-executable.js.map +1 -1
  93. package/dist/transport-factory.d.ts.map +1 -1
  94. package/dist/transport-factory.js +8 -33
  95. package/dist/transport-factory.js.map +1 -1
  96. package/dist/transport.d.ts +2 -0
  97. package/dist/transport.d.ts.map +1 -1
  98. package/dist/transport.js.map +1 -1
  99. package/package.json +1 -1
@@ -17,8 +17,9 @@
17
17
  * published plugin dist; it is never a published runtime dependency.
18
18
  */
19
19
  import { statSync } from "node:fs";
20
- import { AuthError, connectionFileExists, isConsumerReconnectTransient, routeCloseReason, SocketTimeoutError, StaleRouteHandleError, SubcCallError, SubcClient, SubcError, } from "@cortexkit/subc-client";
20
+ import { AuthError, connectionFileExists, isConsumerReconnectTransient, routeCloseReason, SocketClosedError, SocketTimeoutError, StaleRouteHandleError, SubcCallError, SubcClient, SubcError, } from "@cortexkit/subc-client";
21
21
  import { log, warn } from "./active-logger.js";
22
+ import { isTerminalStatus } from "./bash-format.js";
22
23
  import { isRouteOpenReloadWindowError, isRouteRequestReloadRefusal } from "./error-contract.js";
23
24
  import { asCanonicalRootPath, asRootGeneration, LifecycleRegistry, } from "./lifecycle-registry.js";
24
25
  import { canonicalizeProjectRoot } from "./project-identity.js";
@@ -170,6 +171,81 @@ export function isSocketDeathError(error, depth = 0) {
170
171
  }
171
172
  return isConsumerReconnectTransient(error) || isSubcClientClosedError(error);
172
173
  }
174
+ /** Typed system codes for a reset or broken connected socket. */
175
+ const REQUEST_CONNECTION_LOSS_CODES = new Set(["ECONNABORTED", "ECONNRESET", "EPIPE"]);
176
+ function hasRequestConnectionLossCode(error) {
177
+ if (typeof error !== "object" || error === null || !("code" in error))
178
+ return false;
179
+ return typeof error.code === "string" && REQUEST_CONNECTION_LOSS_CODES.has(error.code);
180
+ }
181
+ function hasRequestConnectionLossEvidence(error, depth = 0) {
182
+ if (error instanceof SocketClosedError)
183
+ return true;
184
+ if (error instanceof SubcError && error.closeReason === "connection_lost")
185
+ return true;
186
+ if (hasRequestConnectionLossCode(error))
187
+ return true;
188
+ if (!(error instanceof SubcCallError) ||
189
+ error.kind !== "outcome_unknown" ||
190
+ depth >= 4 ||
191
+ error.cause === undefined ||
192
+ error.cause === error) {
193
+ return false;
194
+ }
195
+ return hasRequestConnectionLossEvidence(error.cause, depth + 1);
196
+ }
197
+ /**
198
+ * A raw request first checks `closedErr`, then records itself pending and starts
199
+ * `writeBorrowed` in the same synchronous send(). A later read-loop failure goes
200
+ * through fail(), which rejects route-pending requests as SubcError with
201
+ * closeReason="connection_lost". That proves the request write was attempted,
202
+ * but the raw request API does not expose whether the socket queued those bytes;
203
+ * the outcome must therefore stay unknown, not be retried.
204
+ *
205
+ * The timer path is different: it rejects with code="request_deadline" while
206
+ * leaving the shared socket potentially live. Read/body timeouts reach fail() and
207
+ * get the connection-lost close reason; a direct SocketTimeoutError from writing
208
+ * can be before or after queueing. The socket's queued/not-queued write wrappers
209
+ * are stripped by the raw write helper, so neither a bare timeout nor a bare
210
+ * SocketClosedError alone identifies the write state. The latter remains
211
+ * supported for older clients that rejected pending calls with that class.
212
+ * Explicit kind="not_sent" is authoritative and always wins over these shapes.
213
+ */
214
+ function connectionClosedAfterDispatch(error) {
215
+ if (error instanceof SubcCallError && error.kind === "not_sent")
216
+ return error;
217
+ if (typeof error === "object" && error !== null && "kind" in error && error.kind === "not_sent") {
218
+ return error;
219
+ }
220
+ if (!hasRequestConnectionLossEvidence(error))
221
+ return error;
222
+ if (error instanceof SubcCallError)
223
+ return error;
224
+ if (!(error instanceof Error))
225
+ return error;
226
+ return Object.assign(error, { kind: "outcome_unknown" });
227
+ }
228
+ /**
229
+ * Requests that subc-client's own deadline rejected after the request had been
230
+ * handed to `client.request`. Unlike the same `request_deadline` code on a
231
+ * route.open (which proves nothing was sent), the request may have reached AFT
232
+ * and started running, so its outcome is unknown. Kept outside the error
233
+ * object so the error's class, code, message and retry handling stay as
234
+ * subc-client made them.
235
+ */
236
+ const dispatchedRequestDeadlines = new WeakSet();
237
+ function noteDispatchedRequestDeadline(error) {
238
+ if (error instanceof SubcError && error.code === REQUEST_DEADLINE_CODE) {
239
+ dispatchedRequestDeadlines.add(error);
240
+ }
241
+ }
242
+ /**
243
+ * True for a request that ran out of subc-client's deadline after it was sent,
244
+ * so AFT may have started it (see `dispatchedRequestDeadlines`).
245
+ */
246
+ export function isDispatchedRequestDeadlineError(error) {
247
+ return typeof error === "object" && error !== null && dispatchedRequestDeadlines.has(error);
248
+ }
173
249
  /**
174
250
  * A call that never got an answer on a socket that may still be alive: the
175
251
  * client's own request deadline (raw `request`/`routeOpen` reject with code
@@ -420,8 +496,10 @@ class BgSubscription {
420
496
  get attachedRoute() {
421
497
  return this.route;
422
498
  }
499
+ cancelBackoff = null;
423
500
  async stop() {
424
501
  this.stopped = true;
502
+ this.cancelBackoff?.();
425
503
  // Wake a live `await sub.closed`; the loop's `finally` remains the sole
426
504
  // owner of closeRouteChannel, avoiding a double close from stop() + run().
427
505
  const sub = this.current;
@@ -668,7 +746,39 @@ class BgSubscription {
668
746
  giveUp("stopped");
669
747
  }
670
748
  async backoff(attempt) {
671
- await this.sleep(reconnectBackoffMs(attempt));
749
+ if (this.stopped)
750
+ return;
751
+ await new Promise((resolve, reject) => {
752
+ let timer;
753
+ let finished = false;
754
+ const settle = (error) => {
755
+ if (finished)
756
+ return;
757
+ finished = true;
758
+ if (timer)
759
+ clearTimeout(timer);
760
+ this.cancelBackoff = null;
761
+ if (error !== undefined)
762
+ reject(error);
763
+ else
764
+ resolve();
765
+ };
766
+ this.cancelBackoff = () => settle();
767
+ const delay = reconnectBackoffMs(attempt);
768
+ if (this.sleep) {
769
+ // An injected sleeper may not be cancellable. Observe its eventual
770
+ // rejection, but let stop release the subscription without awaiting it.
771
+ try {
772
+ void this.sleep(delay).then(() => settle(), settle);
773
+ }
774
+ catch (error) {
775
+ settle(error);
776
+ }
777
+ }
778
+ else {
779
+ timer = setTimeout(() => settle(), delay);
780
+ }
781
+ });
672
782
  }
673
783
  }
674
784
  function isRecord(value) {
@@ -711,17 +821,25 @@ function callDeadlinePassedError(lastError, callDeadlineMs) {
711
821
  return withDeadlineSuffix(lastError, suffix);
712
822
  return new SubcError(`route to module '${AFT_MODULE_ID}' was not ready before the call ran out of time.${suffix}`, REQUEST_DEADLINE_CODE);
713
823
  }
824
+ export const __withDeadlineSuffixForTests = withDeadlineSuffix;
714
825
  function withDeadlineSuffix(error, suffix) {
715
- if (error instanceof Error) {
716
- try {
717
- error.message += suffix;
718
- return error;
719
- }
720
- catch {
721
- // A frozen SDK error cannot carry the suffix; retain its message in a wrapper.
722
- }
723
- }
724
- return new Error(`${String(error)}${suffix}`);
826
+ if (!(error instanceof Error))
827
+ return new Error(`${String(error)}${suffix}`, { cause: error });
828
+ const message = `${error.message}${suffix}`;
829
+ if (error instanceof SubcCallError) {
830
+ const wrapped = new SubcCallError(error.kind, message, error.code, error);
831
+ // SubcCallError's detail getter only unwraps a direct SubcError cause.
832
+ Object.defineProperty(wrapped, "detail", { value: error.detail });
833
+ return wrapped;
834
+ }
835
+ if (error instanceof SubcError) {
836
+ const wrapped = new SubcError(message, error.code, error.detail, error.closeReason);
837
+ Object.defineProperty(wrapped, "cause", { value: error });
838
+ return wrapped;
839
+ }
840
+ const wrapped = new Error(message, { cause: error });
841
+ wrapped.name = error.name;
842
+ return wrapped;
725
843
  }
726
844
  /**
727
845
  * Fire-and-forget route close that can never throw — neither synchronously (a
@@ -924,7 +1042,29 @@ class SubcTransport {
924
1042
  // dropping it here would cap long tool executions at the subc client's
925
1043
  // default unary deadline while the command keeps running module-side.
926
1044
  const timeoutMs = options.transportTimeoutMs ?? options.timeoutMs;
927
- const onProgress = options.onProgress;
1045
+ // subc-client delivers wire bodies; hosts use the same decoded callback
1046
+ // shape as BinaryBridge, regardless of the route carrying the request.
1047
+ const callback = options.onProgress;
1048
+ const onProgress = callback
1049
+ ? (body) => {
1050
+ let frame;
1051
+ try {
1052
+ frame = JSON.parse(new TextDecoder().decode(body));
1053
+ }
1054
+ catch {
1055
+ return;
1056
+ }
1057
+ if (typeof frame !== "object" || frame === null)
1058
+ return;
1059
+ const progress = frame;
1060
+ if (progress.type !== "progress" || typeof progress.chunk !== "string")
1061
+ return;
1062
+ callback({
1063
+ kind: progress.kind === "stderr" ? "stderr" : "stdout",
1064
+ text: progress.chunk,
1065
+ });
1066
+ }
1067
+ : undefined;
928
1068
  return { preview, timeoutMs, onProgress, abortSignal: options.abortSignal };
929
1069
  }
930
1070
  }
@@ -941,7 +1081,7 @@ export class SubcTransportPool {
941
1081
  hostScopes;
942
1082
  /** Scope identity is host-session-wide, even when the session uses multiple project roots. */
943
1083
  hostScopeCache = new Map();
944
- hostScopeRefusals = new Set();
1084
+ hostScopeRefusals = new Map();
945
1085
  hostScopeOutcomes = new Map();
946
1086
  connectFn;
947
1087
  onBgEventsNudge;
@@ -1008,8 +1148,7 @@ export class SubcTransportPool {
1008
1148
  this.connectFn = options.connect ?? ((opts) => SubcClient.connect(opts));
1009
1149
  this.onBgEventsNudge = options.onBgEventsNudge;
1010
1150
  this.onBgEventsNudgeRef = options.onBgEventsNudgeRef;
1011
- this.bgBackoffSleep =
1012
- options.bgBackoffSleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1151
+ this.bgBackoffSleep = options.bgBackoffSleep;
1013
1152
  this.routeRetrySleep =
1014
1153
  options.routeRetrySleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1015
1154
  this.bgDispatchProbeIntervalMs =
@@ -1395,8 +1534,12 @@ export class SubcTransportPool {
1395
1534
  * always nudges a drain, replaying completions queued while no carrier existed.
1396
1535
  */
1397
1536
  async reapIdleSessions(now = Date.now()) {
1398
- for (const [key, state] of this.nudgeDeliveryLogState) {
1399
- if (now - state.lastEmittedAt >= 15 * 60_000)
1537
+ for (const [key, bucket] of this.nudgeDeliveryLogState) {
1538
+ for (const [kind, state] of bucket) {
1539
+ if (now - state.lastEmittedAt >= 15 * 60_000)
1540
+ bucket.delete(kind);
1541
+ }
1542
+ if (bucket.size === 0)
1400
1543
  this.nudgeDeliveryLogState.delete(key);
1401
1544
  }
1402
1545
  const detached = [];
@@ -1484,7 +1627,7 @@ export class SubcTransportPool {
1484
1627
  }
1485
1628
  if (typeof data.task_id === "string") {
1486
1629
  // Terminal tasks remain pinned until the daemon explicitly ACKs them.
1487
- const terminal = ["completed", "failed", "killed", "timed_out"].includes(String(data.status));
1630
+ const terminal = isTerminalStatus(data.status);
1488
1631
  const running = ["starting", "running", "killing"].includes(String(data.status));
1489
1632
  record.backgroundTasks.set(data.task_id, terminal ? "terminal" : running ? "running" : "unknown");
1490
1633
  if (name === "bash" || name === "powershell")
@@ -1569,15 +1712,19 @@ export class SubcTransportPool {
1569
1712
  };
1570
1713
  }
1571
1714
  logNudgeDelivery(kind, record, message) {
1572
- const key = `${kind}\u0000${record.identityKey}`;
1715
+ let bucket = this.nudgeDeliveryLogState.get(record.identityKey);
1716
+ if (!bucket) {
1717
+ bucket = new Map();
1718
+ this.nudgeDeliveryLogState.set(record.identityKey, bucket);
1719
+ }
1573
1720
  const now = Date.now();
1574
- const state = this.nudgeDeliveryLogState.get(key);
1721
+ const state = bucket.get(kind);
1575
1722
  if (state && now - state.lastEmittedAt < BG_LIFECYCLE_LOG_INTERVAL_MS) {
1576
1723
  state.suppressed += 1;
1577
1724
  return;
1578
1725
  }
1579
1726
  const suppressed = state?.suppressed ?? 0;
1580
- this.nudgeDeliveryLogState.set(key, { lastEmittedAt: now, suppressed: 0 });
1727
+ bucket.set(kind, { lastEmittedAt: now, suppressed: 0 });
1581
1728
  const suffix = suppressed > 0 ? ` suppressed=${suppressed}` : "";
1582
1729
  log(`subc bg_events: ${message}${suffix}`, { sessionId: record.identity.session });
1583
1730
  }
@@ -1622,10 +1769,7 @@ export class SubcTransportPool {
1622
1769
  record.teardownReason = reason;
1623
1770
  this.sessions.delete(key);
1624
1771
  this.removeIndexMembership(record);
1625
- for (const logKey of this.nudgeDeliveryLogState.keys()) {
1626
- if (logKey.endsWith(`\u0000${key}`))
1627
- this.nudgeDeliveryLogState.delete(logKey);
1628
- }
1772
+ this.nudgeDeliveryLogState.delete(key);
1629
1773
  const bgSub = record.bgSub;
1630
1774
  record.bgSub = null;
1631
1775
  const routeEntry = record.routeEntry;
@@ -1991,7 +2135,7 @@ export class SubcTransportPool {
1991
2135
  return reply;
1992
2136
  }
1993
2137
  catch (requestError) {
1994
- const error = frameReadTimeoutError(routeClosedMidCallError(requestError, routeAndEntry.entry, record));
2138
+ const error = frameReadTimeoutError(routeClosedMidCallError(connectionClosedAfterDispatch(requestError), routeAndEntry.entry, record));
1995
2139
  if (this.isReapInduced(record))
1996
2140
  throw this.annotateReapError(error, record);
1997
2141
  const ownsRoute = this.isCurrentSession(key, record) && this.client === client;
@@ -2038,6 +2182,7 @@ export class SubcTransportPool {
2038
2182
  continue;
2039
2183
  }
2040
2184
  handleRequestFailure(error, routeAndEntry.entry);
2185
+ noteDispatchedRequestDeadline(error);
2041
2186
  throw error;
2042
2187
  }
2043
2188
  }
@@ -2200,21 +2345,45 @@ export class SubcTransportPool {
2200
2345
  return { route: await opening, entry };
2201
2346
  }
2202
2347
  hostScope(client, identity) {
2203
- if (this.hostScopeRefusals.has(identity.session))
2204
- return Promise.resolve(undefined);
2348
+ let lookupReason = "cache_miss";
2349
+ const refused = this.hostScopeRefusals.get(identity.session);
2350
+ if (refused) {
2351
+ // A refusal applies to one route-binding attempt, not to the session's future scope.
2352
+ // Look up the host scope again on the next bind instead of leaving later routes unscoped.
2353
+ const previous = this.hostScopeCache.get(identity.session);
2354
+ if (previous)
2355
+ this.cancelHostScopeRetry(previous);
2356
+ this.hostScopeCache.delete(identity.session);
2357
+ this.hostScopeRefusals.delete(identity.session);
2358
+ lookupReason = `previous_bind_refusal code=${refused}`;
2359
+ }
2205
2360
  let state = this.hostScopeCache.get(identity.session);
2206
2361
  if (!state) {
2207
2362
  state = { retryAt: 0, retryMs: HOST_SCOPE_RETRY_FLOOR_MS };
2208
2363
  this.hostScopeCache.set(identity.session, state);
2209
2364
  }
2210
2365
  if (state.answer) {
2211
- return Promise.resolve(state.answer.result === "scope" && !state.scopeBindPending ? state.answer.scope : undefined);
2366
+ const reason = state.scopeBindPending
2367
+ ? "scope_sync_pending"
2368
+ : `cached_${state.answer.result}`;
2369
+ log(`subc head scope: session=${identity.session} lookup=skipped reason=${reason}`);
2370
+ return Promise.resolve({
2371
+ scope: state.answer.result === "scope" && !state.scopeBindPending
2372
+ ? state.answer.scope
2373
+ : undefined,
2374
+ reason,
2375
+ });
2212
2376
  }
2213
- if (state.pending)
2377
+ if (state.pending) {
2378
+ log(`subc head scope: session=${identity.session} lookup=skipped reason=inflight_lookup`);
2214
2379
  return state.pending;
2380
+ }
2215
2381
  // Backoff skips the lookup, not the bind, so an unavailable core cannot delay every root.
2216
- if (Date.now() < state.retryAt)
2217
- return Promise.resolve(undefined);
2382
+ if (Date.now() < state.retryAt) {
2383
+ log(`subc head scope: session=${identity.session} lookup=skipped reason=backoff retry_in=${state.retryAt - Date.now()}ms`);
2384
+ return Promise.resolve({ reason: state.unscopedReason ?? "lookup_backoff" });
2385
+ }
2386
+ log(`subc head scope: session=${identity.session} lookup=started reason=${lookupReason}`);
2218
2387
  const current = state;
2219
2388
  clearTimeout(current.retryTimer);
2220
2389
  current.retryTimer = undefined;
@@ -2223,25 +2392,36 @@ export class SubcTransportPool {
2223
2392
  const lookup = this.readHostScope(client, identity, abort.signal).then((outcome) => {
2224
2393
  // A scope-close push may invalidate the session while the management request is in flight.
2225
2394
  if (this.hostScopeCache.get(identity.session) !== current)
2226
- return undefined;
2395
+ return { reason: "lookup_invalidated" };
2227
2396
  current.pending = undefined;
2228
2397
  current.lookupAbort = undefined;
2229
2398
  if (abort.signal.aborted)
2230
- return undefined;
2399
+ return { reason: "lookup_cancelled" };
2231
2400
  this.logHostScopeOutcome(identity.session, outcome);
2401
+ const reason = outcome.result === "error"
2402
+ ? `lookup_error code=${outcome.code}`
2403
+ : `lookup_${outcome.result}`;
2232
2404
  if (outcome.result === "scope" || outcome.result === "no_scope") {
2233
2405
  current.answer = outcome;
2234
2406
  current.retryAt = 0;
2235
2407
  current.retryMs = HOST_SCOPE_RETRY_FLOOR_MS;
2236
- if (outcome.result === "scope")
2237
- this.promoteHostScope(identity.session);
2408
+ if (outcome.result === "scope") {
2409
+ // After a refusal, confirm daemon acceptance before replacing working fallback routes.
2410
+ current.promoteAfterBind = Boolean(refused);
2411
+ if (!current.promoteAfterBind)
2412
+ this.promoteHostScope(identity.session);
2413
+ }
2238
2414
  }
2239
2415
  else {
2416
+ current.unscopedReason = reason;
2240
2417
  current.retryAt = Date.now() + current.retryMs;
2241
2418
  current.retryMs = Math.min(current.retryMs * 2, HOST_SCOPE_RETRY_CAP_MS);
2242
2419
  this.scheduleHostScopeRetry(identity.session, current);
2243
2420
  }
2244
- return outcome.result === "scope" ? outcome.scope : undefined;
2421
+ return {
2422
+ scope: outcome.result === "scope" ? outcome.scope : undefined,
2423
+ reason,
2424
+ };
2245
2425
  });
2246
2426
  current.pending = lookup;
2247
2427
  return lookup;
@@ -2304,7 +2484,7 @@ export class SubcTransportPool {
2304
2484
  retry = this.hostScope(client, record.identity);
2305
2485
  }
2306
2486
  void retry.catch((error) => {
2307
- warn(`subc head scope: background retry failed code=${error instanceof Error ? error.name : "Error"}`);
2487
+ warn(`subc head scope: session=${session} background retry failed code=${error instanceof Error ? error.name : "Error"}`);
2308
2488
  });
2309
2489
  }, Math.max(0, state.retryAt - Date.now()));
2310
2490
  state.retryTimer.unref();
@@ -2320,8 +2500,8 @@ export class SubcTransportPool {
2320
2500
  }
2321
2501
  refuseHostScopeBind(session, state, code) {
2322
2502
  if (!this.hostScopeRefusals.has(session)) {
2323
- this.hostScopeRefusals.add(session);
2324
- warn(`subc head scope: session=${session} scoped bind refused code=${code}; binding unscoped; retry stopped`);
2503
+ this.hostScopeRefusals.set(session, code);
2504
+ warn(`subc head scope: session=${session} scoped bind refused code=${code}; retry stopped until next bind lookup`);
2325
2505
  }
2326
2506
  if (state) {
2327
2507
  state.scopeBindPending = false;
@@ -2332,7 +2512,7 @@ export class SubcTransportPool {
2332
2512
  async retryHostScopeBind(client, identity, state, scope) {
2333
2513
  try {
2334
2514
  // Validate daemon acceptance before retiring a working fallback route or its held requests.
2335
- const route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, { consumerIdentity: this.consumerIdentity, scope });
2515
+ const route = await this.bindHostToolRoute(client, identity, "scope_probe", "scope_sync_retry", scope);
2336
2516
  safeCloseRoute(client, route);
2337
2517
  if (this.hostScopeCache.get(identity.session) !== state ||
2338
2518
  !state.scopeBindPending ||
@@ -2341,6 +2521,7 @@ export class SubcTransportPool {
2341
2521
  !this.unscopedHostRecord(identity.session))
2342
2522
  return;
2343
2523
  state.scopeBindPending = false;
2524
+ state.promoteAfterBind = false;
2344
2525
  state.retryAt = 0;
2345
2526
  state.retryMs = HOST_SCOPE_RETRY_FLOOR_MS;
2346
2527
  log(`subc head scope: session=${identity.session} scoped bind recovered ref=${scope.ref.slice(0, 8)} epoch=${scope.scopeEpoch}`);
@@ -2496,27 +2677,49 @@ export class SubcTransportPool {
2496
2677
  signal.removeEventListener("abort", cancel);
2497
2678
  }
2498
2679
  }
2499
- openHostToolRoute(client, identity, record, entry) {
2680
+ openHostToolRoute(client, identity, record, entry, channel = "tool") {
2500
2681
  if (!this.hostScopes ||
2501
2682
  !["opencode", "pi"].includes(identity.harness) ||
2502
2683
  identity.session === DEFAULT_SESSION_ID) {
2503
- return client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
2504
- consumerIdentity: this.consumerIdentity,
2505
- });
2506
- }
2507
- return this.openScopedHostToolRoute(client, identity, record, entry);
2684
+ const reason = !this.hostScopes
2685
+ ? "host_scopes_disabled"
2686
+ : identity.session === DEFAULT_SESSION_ID
2687
+ ? "default_session"
2688
+ : "unsupported_harness";
2689
+ log(`subc head scope: session=${identity.session} lookup=skipped reason=${reason}`);
2690
+ return this.bindHostToolRoute(client, identity, channel, reason);
2691
+ }
2692
+ return this.openScopedHostToolRoute(client, identity, record, entry, channel);
2693
+ }
2694
+ bindHostToolRoute(client, identity, channel, reason, scope) {
2695
+ log(`subc head scope: session=${identity.session} bind=${scope ? "scoped" : "unscoped"} channel=${channel} root=${identity.project_root} reason=${reason}` +
2696
+ (scope ? ` ref=${scope.ref.slice(0, 8)} epoch=${scope.scopeEpoch}` : ""));
2697
+ return client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
2698
+ consumerIdentity: this.consumerIdentity,
2699
+ ...(scope ? { scope } : {}),
2700
+ });
2508
2701
  }
2509
- async openScopedHostToolRoute(client, identity, record, entry) {
2510
- const scope = await this.hostScope(client, identity);
2702
+ async openScopedHostToolRoute(client, identity, record, entry, channel = "tool") {
2703
+ const decision = await this.hostScope(client, identity);
2704
+ const scope = decision.scope;
2705
+ let reason = decision.reason;
2511
2706
  this.assertRecordLive(record);
2512
2707
  if (scope) {
2513
2708
  try {
2514
- const route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, { consumerIdentity: this.consumerIdentity, scope });
2709
+ const route = await this.bindHostToolRoute(client, identity, channel, reason, scope);
2515
2710
  if (entry)
2516
2711
  entry.hostScope = scope;
2712
+ const state = this.hostScopeCache.get(identity.session);
2713
+ if (state?.promoteAfterBind &&
2714
+ state.answer?.result === "scope" &&
2715
+ state.answer.scope === scope) {
2716
+ state.promoteAfterBind = false;
2717
+ this.promoteHostScope(identity.session);
2718
+ }
2517
2719
  return route;
2518
2720
  }
2519
2721
  catch (error) {
2722
+ log(`subc head scope: session=${identity.session} bind=failed channel=${channel} reason=scoped_open_error code=${error instanceof SubcError ? (error.code ?? error.name) : "Error"}`);
2520
2723
  if (!(error instanceof SubcError) || !error.code || isSocketDeathError(error))
2521
2724
  throw error;
2522
2725
  // A restarting provider has not rejected the scope. Keep it for the normal reopen retry.
@@ -2526,22 +2729,27 @@ export class SubcTransportPool {
2526
2729
  const state = this.hostScopeCache.get(identity.session);
2527
2730
  if (state && isHostScopeSyncRefusal(error.code)) {
2528
2731
  this.deferHostScopeBind(identity.session, state, error.code);
2732
+ reason = `scope_sync_pending code=${error.code}`;
2529
2733
  }
2530
2734
  else {
2531
2735
  this.refuseHostScopeBind(identity.session, state, error.code);
2736
+ // Look up the host scope again after a cached scoped route is refused,
2737
+ // before opening an unscoped route.
2738
+ if (decision.reason === "cached_scope") {
2739
+ return this.openScopedHostToolRoute(client, identity, record, entry, channel);
2740
+ }
2741
+ reason = `scoped_bind_refused code=${error.code}`;
2532
2742
  }
2533
2743
  }
2534
2744
  }
2535
2745
  this.assertRecordLive(record);
2536
- const route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
2537
- consumerIdentity: this.consumerIdentity,
2538
- });
2746
+ const route = await this.bindHostToolRoute(client, identity, channel, reason);
2539
2747
  // Scope recovery can finish while a fallback bind waits for the provider, including bg_events.
2540
2748
  if (this.hostScopeCache.get(identity.session)?.answer?.result === "scope" &&
2541
2749
  !this.hostScopeCache.get(identity.session)?.scopeBindPending &&
2542
2750
  !this.hostScopeRefusals.has(identity.session)) {
2543
2751
  safeCloseRoute(client, route);
2544
- return this.openScopedHostToolRoute(client, identity, record, entry);
2752
+ return this.openScopedHostToolRoute(client, identity, record, entry, channel);
2545
2753
  }
2546
2754
  return route;
2547
2755
  }
@@ -2596,6 +2804,15 @@ export class SubcTransportPool {
2596
2804
  if (!this.rootCanAttach(record.canonicalRoot))
2597
2805
  return;
2598
2806
  const nudgeRef = this.nudgeRefFor(record);
2807
+ const dispatch = (callback) => {
2808
+ const failed = (error) => warn(`subc bg nudge delivery failed: ${describeFailure(error)}`);
2809
+ try {
2810
+ void Promise.resolve(callback()).catch(failed);
2811
+ }
2812
+ catch (error) {
2813
+ failed(error);
2814
+ }
2815
+ };
2599
2816
  const onNudge = (observedEvent) => {
2600
2817
  const currentRecord = this.currentSessionForNudge(identity);
2601
2818
  if (!currentRecord) {
@@ -2611,14 +2828,14 @@ export class SubcTransportPool {
2611
2828
  const currentRef = this.nudgeRefFor(currentRecord);
2612
2829
  let delivered = false;
2613
2830
  if (currentRef && this.onBgEventsNudgeRef) {
2614
- this.onBgEventsNudgeRef(currentRef);
2831
+ dispatch(() => this.onBgEventsNudgeRef?.(currentRef));
2615
2832
  delivered = true;
2616
2833
  }
2617
2834
  if (this.onBgEventsNudge) {
2618
2835
  if (!currentRef && this.onBgEventsNudgeRef) {
2619
2836
  this.logNudgeDelivery("fallback-missing-generation", currentRecord, `nudge dispatch fallback=root-session-handler cause=generation-provenance-unavailable root=${currentRecord.canonicalRoot}`);
2620
2837
  }
2621
- this.onBgEventsNudge(currentRecord.identity.project_root, currentRecord.identity.session);
2838
+ dispatch(() => this.onBgEventsNudge?.(currentRecord.identity.project_root, currentRecord.identity.session));
2622
2839
  delivered = true;
2623
2840
  }
2624
2841
  if (delivered)
@@ -2633,7 +2850,7 @@ export class SubcTransportPool {
2633
2850
  };
2634
2851
  sub = new BgSubscription(identity, () => this.ensureClient(), (client, cause) => this.dropClient(client, cause), (client) => {
2635
2852
  binding.hostScope = undefined;
2636
- return this.openHostToolRoute(client, identity, record, binding);
2853
+ return this.openHostToolRoute(client, identity, record, binding, "bg_events");
2637
2854
  }, onNudge, this.bgBackoffSleep, () => this.rootCanAttach(record.canonicalRoot), (error) => {
2638
2855
  if (!isAbsentRootRouteError(error))
2639
2856
  return false;