@cortexkit/aft-bridge 0.61.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.
@@ -17,7 +17,7 @@
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
22
  import { isTerminalStatus } from "./bash-format.js";
23
23
  import { isRouteOpenReloadWindowError, isRouteRequestReloadRefusal } from "./error-contract.js";
@@ -171,6 +171,81 @@ export function isSocketDeathError(error, depth = 0) {
171
171
  }
172
172
  return isConsumerReconnectTransient(error) || isSubcClientClosedError(error);
173
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
+ }
174
249
  /**
175
250
  * A call that never got an answer on a socket that may still be alive: the
176
251
  * client's own request deadline (raw `request`/`routeOpen` reject with code
@@ -967,7 +1042,29 @@ class SubcTransport {
967
1042
  // dropping it here would cap long tool executions at the subc client's
968
1043
  // default unary deadline while the command keeps running module-side.
969
1044
  const timeoutMs = options.transportTimeoutMs ?? options.timeoutMs;
970
- 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;
971
1068
  return { preview, timeoutMs, onProgress, abortSignal: options.abortSignal };
972
1069
  }
973
1070
  }
@@ -984,7 +1081,7 @@ export class SubcTransportPool {
984
1081
  hostScopes;
985
1082
  /** Scope identity is host-session-wide, even when the session uses multiple project roots. */
986
1083
  hostScopeCache = new Map();
987
- hostScopeRefusals = new Set();
1084
+ hostScopeRefusals = new Map();
988
1085
  hostScopeOutcomes = new Map();
989
1086
  connectFn;
990
1087
  onBgEventsNudge;
@@ -2038,7 +2135,7 @@ export class SubcTransportPool {
2038
2135
  return reply;
2039
2136
  }
2040
2137
  catch (requestError) {
2041
- const error = frameReadTimeoutError(routeClosedMidCallError(requestError, routeAndEntry.entry, record));
2138
+ const error = frameReadTimeoutError(routeClosedMidCallError(connectionClosedAfterDispatch(requestError), routeAndEntry.entry, record));
2042
2139
  if (this.isReapInduced(record))
2043
2140
  throw this.annotateReapError(error, record);
2044
2141
  const ownsRoute = this.isCurrentSession(key, record) && this.client === client;
@@ -2085,6 +2182,7 @@ export class SubcTransportPool {
2085
2182
  continue;
2086
2183
  }
2087
2184
  handleRequestFailure(error, routeAndEntry.entry);
2185
+ noteDispatchedRequestDeadline(error);
2088
2186
  throw error;
2089
2187
  }
2090
2188
  }
@@ -2247,21 +2345,45 @@ export class SubcTransportPool {
2247
2345
  return { route: await opening, entry };
2248
2346
  }
2249
2347
  hostScope(client, identity) {
2250
- if (this.hostScopeRefusals.has(identity.session))
2251
- 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
+ }
2252
2360
  let state = this.hostScopeCache.get(identity.session);
2253
2361
  if (!state) {
2254
2362
  state = { retryAt: 0, retryMs: HOST_SCOPE_RETRY_FLOOR_MS };
2255
2363
  this.hostScopeCache.set(identity.session, state);
2256
2364
  }
2257
2365
  if (state.answer) {
2258
- 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
+ });
2259
2376
  }
2260
- if (state.pending)
2377
+ if (state.pending) {
2378
+ log(`subc head scope: session=${identity.session} lookup=skipped reason=inflight_lookup`);
2261
2379
  return state.pending;
2380
+ }
2262
2381
  // Backoff skips the lookup, not the bind, so an unavailable core cannot delay every root.
2263
- if (Date.now() < state.retryAt)
2264
- 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}`);
2265
2387
  const current = state;
2266
2388
  clearTimeout(current.retryTimer);
2267
2389
  current.retryTimer = undefined;
@@ -2270,25 +2392,36 @@ export class SubcTransportPool {
2270
2392
  const lookup = this.readHostScope(client, identity, abort.signal).then((outcome) => {
2271
2393
  // A scope-close push may invalidate the session while the management request is in flight.
2272
2394
  if (this.hostScopeCache.get(identity.session) !== current)
2273
- return undefined;
2395
+ return { reason: "lookup_invalidated" };
2274
2396
  current.pending = undefined;
2275
2397
  current.lookupAbort = undefined;
2276
2398
  if (abort.signal.aborted)
2277
- return undefined;
2399
+ return { reason: "lookup_cancelled" };
2278
2400
  this.logHostScopeOutcome(identity.session, outcome);
2401
+ const reason = outcome.result === "error"
2402
+ ? `lookup_error code=${outcome.code}`
2403
+ : `lookup_${outcome.result}`;
2279
2404
  if (outcome.result === "scope" || outcome.result === "no_scope") {
2280
2405
  current.answer = outcome;
2281
2406
  current.retryAt = 0;
2282
2407
  current.retryMs = HOST_SCOPE_RETRY_FLOOR_MS;
2283
- if (outcome.result === "scope")
2284
- 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
+ }
2285
2414
  }
2286
2415
  else {
2416
+ current.unscopedReason = reason;
2287
2417
  current.retryAt = Date.now() + current.retryMs;
2288
2418
  current.retryMs = Math.min(current.retryMs * 2, HOST_SCOPE_RETRY_CAP_MS);
2289
2419
  this.scheduleHostScopeRetry(identity.session, current);
2290
2420
  }
2291
- return outcome.result === "scope" ? outcome.scope : undefined;
2421
+ return {
2422
+ scope: outcome.result === "scope" ? outcome.scope : undefined,
2423
+ reason,
2424
+ };
2292
2425
  });
2293
2426
  current.pending = lookup;
2294
2427
  return lookup;
@@ -2351,7 +2484,7 @@ export class SubcTransportPool {
2351
2484
  retry = this.hostScope(client, record.identity);
2352
2485
  }
2353
2486
  void retry.catch((error) => {
2354
- 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"}`);
2355
2488
  });
2356
2489
  }, Math.max(0, state.retryAt - Date.now()));
2357
2490
  state.retryTimer.unref();
@@ -2367,8 +2500,8 @@ export class SubcTransportPool {
2367
2500
  }
2368
2501
  refuseHostScopeBind(session, state, code) {
2369
2502
  if (!this.hostScopeRefusals.has(session)) {
2370
- this.hostScopeRefusals.add(session);
2371
- 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`);
2372
2505
  }
2373
2506
  if (state) {
2374
2507
  state.scopeBindPending = false;
@@ -2379,7 +2512,7 @@ export class SubcTransportPool {
2379
2512
  async retryHostScopeBind(client, identity, state, scope) {
2380
2513
  try {
2381
2514
  // Validate daemon acceptance before retiring a working fallback route or its held requests.
2382
- 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);
2383
2516
  safeCloseRoute(client, route);
2384
2517
  if (this.hostScopeCache.get(identity.session) !== state ||
2385
2518
  !state.scopeBindPending ||
@@ -2388,6 +2521,7 @@ export class SubcTransportPool {
2388
2521
  !this.unscopedHostRecord(identity.session))
2389
2522
  return;
2390
2523
  state.scopeBindPending = false;
2524
+ state.promoteAfterBind = false;
2391
2525
  state.retryAt = 0;
2392
2526
  state.retryMs = HOST_SCOPE_RETRY_FLOOR_MS;
2393
2527
  log(`subc head scope: session=${identity.session} scoped bind recovered ref=${scope.ref.slice(0, 8)} epoch=${scope.scopeEpoch}`);
@@ -2543,27 +2677,49 @@ export class SubcTransportPool {
2543
2677
  signal.removeEventListener("abort", cancel);
2544
2678
  }
2545
2679
  }
2546
- openHostToolRoute(client, identity, record, entry) {
2680
+ openHostToolRoute(client, identity, record, entry, channel = "tool") {
2547
2681
  if (!this.hostScopes ||
2548
2682
  !["opencode", "pi"].includes(identity.harness) ||
2549
2683
  identity.session === DEFAULT_SESSION_ID) {
2550
- return client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
2551
- consumerIdentity: this.consumerIdentity,
2552
- });
2553
- }
2554
- 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
+ });
2555
2701
  }
2556
- async openScopedHostToolRoute(client, identity, record, entry) {
2557
- 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;
2558
2706
  this.assertRecordLive(record);
2559
2707
  if (scope) {
2560
2708
  try {
2561
- 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);
2562
2710
  if (entry)
2563
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
+ }
2564
2719
  return route;
2565
2720
  }
2566
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"}`);
2567
2723
  if (!(error instanceof SubcError) || !error.code || isSocketDeathError(error))
2568
2724
  throw error;
2569
2725
  // A restarting provider has not rejected the scope. Keep it for the normal reopen retry.
@@ -2573,22 +2729,27 @@ export class SubcTransportPool {
2573
2729
  const state = this.hostScopeCache.get(identity.session);
2574
2730
  if (state && isHostScopeSyncRefusal(error.code)) {
2575
2731
  this.deferHostScopeBind(identity.session, state, error.code);
2732
+ reason = `scope_sync_pending code=${error.code}`;
2576
2733
  }
2577
2734
  else {
2578
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}`;
2579
2742
  }
2580
2743
  }
2581
2744
  }
2582
2745
  this.assertRecordLive(record);
2583
- const route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, identity, {
2584
- consumerIdentity: this.consumerIdentity,
2585
- });
2746
+ const route = await this.bindHostToolRoute(client, identity, channel, reason);
2586
2747
  // Scope recovery can finish while a fallback bind waits for the provider, including bg_events.
2587
2748
  if (this.hostScopeCache.get(identity.session)?.answer?.result === "scope" &&
2588
2749
  !this.hostScopeCache.get(identity.session)?.scopeBindPending &&
2589
2750
  !this.hostScopeRefusals.has(identity.session)) {
2590
2751
  safeCloseRoute(client, route);
2591
- return this.openScopedHostToolRoute(client, identity, record, entry);
2752
+ return this.openScopedHostToolRoute(client, identity, record, entry, channel);
2592
2753
  }
2593
2754
  return route;
2594
2755
  }
@@ -2689,7 +2850,7 @@ export class SubcTransportPool {
2689
2850
  };
2690
2851
  sub = new BgSubscription(identity, () => this.ensureClient(), (client, cause) => this.dropClient(client, cause), (client) => {
2691
2852
  binding.hostScope = undefined;
2692
- return this.openHostToolRoute(client, identity, record, binding);
2853
+ return this.openHostToolRoute(client, identity, record, binding, "bg_events");
2693
2854
  }, onNudge, this.bgBackoffSleep, () => this.rootCanAttach(record.canonicalRoot), (error) => {
2694
2855
  if (!isAbsentRootRouteError(error))
2695
2856
  return false;