@cortexkit/aft-bridge 0.49.4 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,6 +40,8 @@ const MAX_CONSECUTIVE_TRANSPORT_FAILURES = 3;
40
40
  * (otherwise a permanently-failing route resubscribes in a 100ms hot loop). (B-#2.)
41
41
  */
42
42
  const BG_STABLE_MS = 5_000;
43
+ const BG_LIFECYCLE_LOG_INTERVAL_MS = 60_000;
44
+ const BG_DISPATCH_PROBE_INTERVAL_MS = 60_000;
43
45
  /**
44
46
  * Session fallback when a tool runtime carries no session id, mirroring the Rust
45
47
  * `DEFAULT_SESSION_ID` (`protocol.rs`). Keeps undo/checkpoint/bash namespacing
@@ -104,19 +106,12 @@ function identityKey(identity) {
104
106
  return `${identity.project_root}\u0000${identity.harness}\u0000${identity.session}`;
105
107
  }
106
108
  /**
107
- * One session's held-open bg_events subscription with its OWN reconnect driver.
109
+ * One session's held-open bg_events subscription with its own reconnect driver.
108
110
  *
109
- * The idle-stranding fix (Oracle bg_fc2d4119 #3): the resubscribe loop is
110
- * INDEPENDENT of tool calls. When the subscription's `closed` promise rejects (a
111
- * socket drop / route GOODBYE / Error), the loop itself reconnects (via the pool's
112
- * shared single-flight client) and resubscribes — it never waits for a future tool
113
- * call. So an idle agent (no tool traffic) whose connection drops is still woken
114
- * for a completion that landed while disconnected (the durable Rust registry holds
115
- * it until acked; resubscribe + the immediate forced-drain replay it).
116
- *
117
- * The loop is a single sequential async task (only one attempt in flight at a
118
- * time). `stopped`, the captured root-generation check, and one instance per
119
- * identity prevent duplicate or stale subscriptions.
111
+ * The reconnect loop is independent of tool calls, including provider StreamEnd:
112
+ * the module emits StreamEnd when a route is replaced, so only `stop()` proves
113
+ * that a clean end was requested by this consumer. Reopening the dedicated route
114
+ * also forces an outbox drain, recovering completions queued while disconnected.
120
115
  */
121
116
  class BgSubscription {
122
117
  identity;
@@ -128,13 +123,16 @@ class BgSubscription {
128
123
  canAttach;
129
124
  onRootAttachFailure;
130
125
  onDormant;
126
+ dispatchProbeIntervalMs;
131
127
  nudgeRef;
132
128
  isCurrent;
133
129
  stopped = false;
134
130
  /** The live subscription handle, read by stop() to wake the loop's `await closed`. */
135
131
  current = null;
136
132
  loop;
137
- constructor(identity, acquireClient, dropClient, consumerIdentity, onNudge, sleep, canAttach, onRootAttachFailure, onDormant, nudgeRef, isCurrent = () => true) {
133
+ lifecycleLogState = new Map();
134
+ nudgeReceiptLogState = null;
135
+ constructor(identity, acquireClient, dropClient, consumerIdentity, onNudge, sleep, canAttach, onRootAttachFailure, onDormant, dispatchProbeIntervalMs, nudgeRef, isCurrent = () => true) {
138
136
  this.identity = identity;
139
137
  this.acquireClient = acquireClient;
140
138
  this.dropClient = dropClient;
@@ -144,17 +142,15 @@ class BgSubscription {
144
142
  this.canAttach = canAttach;
145
143
  this.onRootAttachFailure = onRootAttachFailure;
146
144
  this.onDormant = onDormant;
145
+ this.dispatchProbeIntervalMs = dispatchProbeIntervalMs;
147
146
  this.nudgeRef = nudgeRef;
148
147
  this.isCurrent = isCurrent;
149
148
  this.loop = this.run();
150
149
  }
151
150
  async stop() {
152
151
  this.stopped = true;
153
- // Wake a live `await sub.closed` so the loop unwinds via its StreamEnd path,
154
- // where the `finally` is the SOLE owner of closeRouteChannel (so the channel
155
- // is closed exactly once, never double-closed by stop() + the loop). If the
156
- // loop is between routeOpen and subscribe, its post-subscribe `stopped`
157
- // re-check (or the pre-subscribe check) closes/returns instead.
152
+ // Wake a live `await sub.closed`; the loop's `finally` remains the sole
153
+ // owner of closeRouteChannel, avoiding a double close from stop() + run().
158
154
  const sub = this.current;
159
155
  if (sub) {
160
156
  try {
@@ -166,98 +162,200 @@ class BgSubscription {
166
162
  }
167
163
  await this.loop.catch(() => undefined);
168
164
  }
165
+ info(kind, message) {
166
+ const now = Date.now();
167
+ const state = this.lifecycleLogState.get(kind);
168
+ if (state && now - state.lastEmittedAt < BG_LIFECYCLE_LOG_INTERVAL_MS) {
169
+ state.suppressed += 1;
170
+ return;
171
+ }
172
+ const suppressed = state?.suppressed ?? 0;
173
+ this.lifecycleLogState.set(kind, { lastEmittedAt: now, suppressed: 0 });
174
+ const suffix = suppressed > 0 ? ` suppressed=${suppressed}` : "";
175
+ log(`subc bg_events: ${message}${suffix}`, { sessionId: this.identity.session });
176
+ }
177
+ routeId(route) {
178
+ return `${route.channel}@${route.epoch}`;
179
+ }
180
+ recordNudgeReceipt(routeId) {
181
+ const now = Date.now();
182
+ const state = this.nudgeReceiptLogState;
183
+ if (state && now - state.lastEmittedAt < BG_LIFECYCLE_LOG_INTERVAL_MS) {
184
+ state.count += 1;
185
+ return;
186
+ }
187
+ const count = (state?.count ?? 0) + 1;
188
+ this.nudgeReceiptLogState = { lastEmittedAt: now, count: 0 };
189
+ log(`subc bg_events: nudge received channel=${routeId} count=${count}`, {
190
+ sessionId: this.identity.session,
191
+ });
192
+ }
193
+ errorText(error) {
194
+ return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
195
+ }
196
+ startDispatchProbe(client, routeId) {
197
+ const initial = client.droppedIngressFrames;
198
+ if (typeof initial !== "number")
199
+ return () => undefined;
200
+ let previous = initial;
201
+ const timer = setInterval(() => {
202
+ const total = client.droppedIngressFrames;
203
+ if (typeof total !== "number" || total <= previous)
204
+ return;
205
+ const delta = total - previous;
206
+ previous = total;
207
+ this.info("dispatch-epoch-drop", `client ingress epoch drops scope=client observed_while_channel=${routeId} delta=${delta} total=${total}`);
208
+ }, this.dispatchProbeIntervalMs);
209
+ timer.unref?.();
210
+ return () => clearInterval(timer);
211
+ }
169
212
  async run() {
170
- let attempt = 0;
213
+ let backoffAttempt = 0;
214
+ let reconnectAttempt = 0;
215
+ let reconnecting = false;
216
+ const beginReconnect = () => {
217
+ reconnecting = true;
218
+ reconnectAttempt = reconnectAttempt === 0 ? 1 : reconnectAttempt + 1;
219
+ };
220
+ const giveUp = (reason) => {
221
+ this.info("reconnect-gave-up", `reconnect gave-up attempt=${reconnectAttempt} reason=${reason}`);
222
+ };
171
223
  while (!this.stopped) {
172
- if (!this.isCurrent())
224
+ if (!this.isCurrent()) {
225
+ if (reconnecting)
226
+ giveUp("stale-session");
173
227
  return;
228
+ }
174
229
  if (!this.canAttach()) {
230
+ if (reconnecting)
231
+ giveUp("root-dormant");
175
232
  this.onDormant();
176
233
  return;
177
234
  }
235
+ if (reconnecting) {
236
+ this.info("reconnect-attempt", `reconnect attempt=${reconnectAttempt}`);
237
+ }
178
238
  let client;
179
239
  try {
180
240
  client = await this.acquireClient();
181
241
  }
182
- catch {
183
- await this.backoff(attempt++);
242
+ catch (err) {
243
+ if (!reconnecting)
244
+ beginReconnect();
245
+ this.info("reconnect-error", `reconnect error attempt=${reconnectAttempt} error=${this.errorText(err)}`);
246
+ await this.backoff(backoffAttempt++);
247
+ if (reconnecting)
248
+ reconnectAttempt += 1;
184
249
  continue;
185
250
  }
186
- if (this.stopped || !this.isCurrent())
251
+ if (this.stopped) {
252
+ if (reconnecting)
253
+ giveUp("stopped");
254
+ return;
255
+ }
256
+ if (!this.isCurrent()) {
257
+ if (reconnecting)
258
+ giveUp("stale-session");
187
259
  return;
260
+ }
188
261
  if (!this.canAttach()) {
262
+ if (reconnecting)
263
+ giveUp("root-dormant");
189
264
  this.onDormant();
190
265
  return;
191
266
  }
192
267
  let route;
193
268
  try {
194
- // A second dedicated route isolates bg_events from the tool route's
269
+ // A dedicated route keeps bg_events independent of the tool route's
195
270
  // credit window while preserving the client's connection-bound handle.
196
271
  route = await client.routeOpen({ kind: "tool_provider", module_id: AFT_MODULE_ID }, this.identity, { consumerIdentity: this.consumerIdentity });
197
272
  }
198
273
  catch (err) {
199
274
  if (this.isCurrent() && this.onRootAttachFailure(err)) {
275
+ if (reconnecting)
276
+ giveUp("root-dormant");
200
277
  this.onDormant();
201
278
  return;
202
279
  }
203
- // routeOpen failed. If it signals a dead CONNECTION, drop the shared
204
- // client so the next `acquireClient` reconnects fresh — the idle-stranding
205
- // fix (B-#1): `acquireClient` returns the cached client, so without this an
206
- // idle bg loop would resubscribe forever onto the same dead socket.
207
280
  if (isConsumerReconnectTransient(err))
208
281
  this.dropClient(client);
209
- await this.backoff(attempt++);
282
+ if (!reconnecting)
283
+ beginReconnect();
284
+ this.info("reconnect-error", `reconnect error attempt=${reconnectAttempt} error=${this.errorText(err)}`);
285
+ await this.backoff(backoffAttempt++);
286
+ if (reconnecting)
287
+ reconnectAttempt += 1;
210
288
  continue;
211
289
  }
212
290
  if (this.stopped || !this.isCurrent()) {
213
291
  safeCloseRoute(client, route);
292
+ if (reconnecting)
293
+ giveUp(this.stopped ? "stopped" : "stale-session");
214
294
  return;
215
295
  }
216
- // Route lifetime: the `finally` guarantees closeRouteChannel on every exit
217
- // path from here (StreamEnd return, drop+resubscribe, stopped) so the
218
- // dedicated route never leaks (B-#2).
219
296
  const subscribedAt = Date.now();
297
+ const routeId = this.routeId(route);
298
+ let stopDispatchProbe = () => undefined;
220
299
  try {
221
300
  const sub = client.subscribe(route, { op: "bg_events" }, () => {
222
- if (!this.stopped && this.isCurrent())
223
- this.onNudge();
301
+ if (this.stopped) {
302
+ this.info("nudge-drop-stopped", `nudge dropped cause=subscription-stopped channel=${routeId}`);
303
+ return;
304
+ }
305
+ this.recordNudgeReceipt(routeId);
306
+ if (!this.isCurrent()) {
307
+ this.info("nudge-stale-carrier", `nudge carried by stale subscription; checking current session channel=${routeId}`);
308
+ }
309
+ this.onNudge();
224
310
  });
225
311
  this.current = sub;
226
- // stop() may have fired between the pre-subscribe check and here; self-
227
- // unsubscribe so the await below resolves immediately (avoids a stop()
228
- // that hangs awaiting a subscription nobody woke).
312
+ stopDispatchProbe = this.startDispatchProbe(client, routeId);
313
+ this.info("subscription-open", `subscription open channel=${routeId}`);
314
+ if (reconnecting) {
315
+ this.info("reconnect-success", `reconnect success attempt=${reconnectAttempt} channel=${routeId}`);
316
+ reconnecting = false;
317
+ reconnectAttempt = 0;
318
+ }
319
+ // stop() may have fired between routeOpen and subscribe. Unsubscribe so
320
+ // the held `closed` promise resolves and the route can unwind.
229
321
  if (this.stopped)
230
322
  sub.unsubscribe();
231
- // Immediate forced-drain replay: a completion that landed while we were
232
- // disconnected is recovered now (resubscribe == the outbox replay trigger).
323
+ // A reconnect may have missed completions, so force a drain after every
324
+ // successful subscribe to replay anything queued while the stream was down.
233
325
  if (!this.stopped && this.isCurrent())
234
326
  this.onNudge();
235
327
  await sub.closed;
236
- // StreamEnd = an intentional close (our unsubscribe or module teardown).
237
- // Do NOT resubscribe.
238
- return;
328
+ this.info("stream-end", `stream ended channel=${routeId}`);
329
+ if (this.stopped) {
330
+ giveUp("stopped");
331
+ return;
332
+ }
333
+ // Provider StreamEnd is also emitted for route replacement; without an
334
+ // explicit local stop it must reopen rather than strand an idle session.
335
+ beginReconnect();
239
336
  }
240
337
  catch (err) {
241
- // Dropped (socket death / route GOODBYE / Error). Resubscribe — this is
242
- // the independent reconnect driver that fixes idle-stranding.
243
- if (this.stopped)
338
+ const routeId = this.routeId(route);
339
+ this.info("stream-error", `stream error channel=${routeId} error=${this.errorText(err)}`);
340
+ if (this.stopped) {
341
+ giveUp("stopped");
244
342
  return;
245
- // A dead-connection drop must replace the client (B-#1); a route-only
246
- // GOODBYE keeps the client and just re-opens a fresh route.
343
+ }
247
344
  if (isConsumerReconnectTransient(err))
248
345
  this.dropClient(client);
249
- // Reset backoff ONLY if the subscription was stable before dropping;
250
- // otherwise a permanently-failing route would resubscribe in a 100ms hot
251
- // loop and never reach the cap (B-#2).
252
346
  if (Date.now() - subscribedAt >= BG_STABLE_MS)
253
- attempt = 0;
347
+ backoffAttempt = 0;
348
+ beginReconnect();
254
349
  }
255
350
  finally {
351
+ stopDispatchProbe();
256
352
  this.current = null;
257
353
  safeCloseRoute(client, route);
258
354
  }
259
- await this.backoff(attempt++);
355
+ await this.backoff(backoffAttempt++);
260
356
  }
357
+ if (reconnecting)
358
+ giveUp("stopped");
261
359
  }
262
360
  async backoff(attempt) {
263
361
  const ms = Math.min(100 * 2 ** Math.min(attempt, 6), 2000);
@@ -399,6 +497,9 @@ class SubcTransport {
399
497
  this.assertCurrent();
400
498
  const { preview, timeoutMs, onProgress } = this.splitOptions(options);
401
499
  const body = { name, arguments: rawArgs };
500
+ const editSlotSurvives = this.pool.getEditSlotSurvives();
501
+ if (editSlotSurvives !== undefined)
502
+ body.edit_slot_survives = editSlotSurvives;
402
503
  if (preview === true)
403
504
  body.preview = true;
404
505
  const reply = await this.pool.routeRequest(this.identityFor(sessionId), body, timeoutMs, onProgress, this.generation);
@@ -423,7 +524,11 @@ class SubcTransport {
423
524
  }
424
525
  const { timeoutMs, onProgress } = this.splitOptions(options);
425
526
  const session = typeof params.session_id === "string" ? params.session_id : undefined;
426
- const reply = await this.pool.routeRequest(this.identityFor(session), { name: command, arguments: params }, timeoutMs, onProgress, this.generation);
527
+ const body = { name: command, arguments: params };
528
+ const editSlotSurvives = this.pool.getEditSlotSurvives();
529
+ if (editSlotSurvives !== undefined)
530
+ body.edit_slot_survives = editSlotSurvives;
531
+ const reply = await this.pool.routeRequest(this.identityFor(session), body, timeoutMs, onProgress, this.generation);
427
532
  const response = reliftReply(reply);
428
533
  this.captureStatusBar(response);
429
534
  return response;
@@ -455,6 +560,7 @@ export class SubcTransportPool {
455
560
  onBgEventsNudge;
456
561
  onBgEventsNudgeRef;
457
562
  bgBackoffSleep;
563
+ bgDispatchProbeIntervalMs;
458
564
  lifecycleDemandCheck;
459
565
  onLifecycleEvent;
460
566
  onBgNudgeRejected;
@@ -479,8 +585,11 @@ export class SubcTransportPool {
479
585
  /** Concrete per-root facades, including their captured root generation. */
480
586
  transports = new Map();
481
587
  generationRejections = new Set();
588
+ nudgeDeliveryLogState = new Map();
482
589
  pendingRootCleanups = new Set();
483
590
  shuttingDown = false;
591
+ editSlotSurvives;
592
+ editSlotSurvivesCaptured = false;
484
593
  constructor(options) {
485
594
  this.connectionFile = options.connectionFile;
486
595
  this.harness = options.harness;
@@ -491,6 +600,8 @@ export class SubcTransportPool {
491
600
  this.onBgEventsNudgeRef = options.onBgEventsNudgeRef;
492
601
  this.bgBackoffSleep =
493
602
  options.bgBackoffSleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
603
+ this.bgDispatchProbeIntervalMs =
604
+ options.bgDispatchProbeIntervalMs ?? BG_DISPATCH_PROBE_INTERVAL_MS;
494
605
  const lifecycle = options.lifecycle;
495
606
  const demandCheck = options.lifecycleDemandCheck ?? options.demandCheck ?? lifecycle?.demandCheck;
496
607
  this.lifecycleDemandCheck = demandCheck;
@@ -852,6 +963,35 @@ export class SubcTransportPool {
852
963
  isCurrentSession(key, record) {
853
964
  return this.sessions.get(key) === record && !record.closed;
854
965
  }
966
+ currentSessionForNudge(identity) {
967
+ const current = this.sessions.get(identityKey(identity));
968
+ return current && !current.closed ? current : null;
969
+ }
970
+ nudgeRefFor(record) {
971
+ const poolId = this.currentPoolId();
972
+ const generation = record.generation;
973
+ if (poolId === undefined || generation === undefined)
974
+ return undefined;
975
+ return {
976
+ canonicalRoot: record.canonicalRoot,
977
+ session: record.identity.session,
978
+ concretePoolId: poolId,
979
+ generation,
980
+ };
981
+ }
982
+ logNudgeDelivery(kind, record, message) {
983
+ const key = `${kind}\u0000${record.identityKey}`;
984
+ const now = Date.now();
985
+ const state = this.nudgeDeliveryLogState.get(key);
986
+ if (state && now - state.lastEmittedAt < BG_LIFECYCLE_LOG_INTERVAL_MS) {
987
+ state.suppressed += 1;
988
+ return;
989
+ }
990
+ const suppressed = state?.suppressed ?? 0;
991
+ this.nudgeDeliveryLogState.set(key, { lastEmittedAt: now, suppressed: 0 });
992
+ const suffix = suppressed > 0 ? ` suppressed=${suppressed}` : "";
993
+ log(`subc bg_events: ${message}${suffix}`, { sessionId: record.identity.session });
994
+ }
855
995
  removeIndexMembership(record) {
856
996
  const keys = this.rootIndex.get(record.canonicalRoot);
857
997
  if (!keys)
@@ -1146,22 +1286,32 @@ export class SubcTransportPool {
1146
1286
  return;
1147
1287
  if (record.bgSub)
1148
1288
  return;
1149
- const poolId = this.currentPoolId();
1150
- const generation = record.generation;
1151
- const nudgeRef = poolId !== undefined && generation !== undefined
1152
- ? {
1153
- canonicalRoot: record.canonicalRoot,
1154
- session: identity.session,
1155
- concretePoolId: poolId,
1156
- generation,
1157
- }
1158
- : undefined;
1289
+ const nudgeRef = this.nudgeRefFor(record);
1159
1290
  const onNudge = () => {
1160
- if (!this.isCurrentSession(record.identityKey, record))
1291
+ const currentRecord = this.currentSessionForNudge(identity);
1292
+ if (!currentRecord) {
1293
+ this.logNudgeDelivery("drop-no-current-session", record, `nudge dropped cause=no-current-session root=${record.canonicalRoot}`);
1294
+ return;
1295
+ }
1296
+ if (currentRecord !== record) {
1297
+ this.logNudgeDelivery("forward-superseded-carrier", currentRecord, `nudge forwarding cause=superseded-carrying-record root=${currentRecord.canonicalRoot}`);
1298
+ }
1299
+ const currentRef = this.nudgeRefFor(currentRecord);
1300
+ let delivered = false;
1301
+ if (currentRef && this.onBgEventsNudgeRef) {
1302
+ this.onBgEventsNudgeRef(currentRef);
1303
+ delivered = true;
1304
+ }
1305
+ if (this.onBgEventsNudge) {
1306
+ if (!currentRef && this.onBgEventsNudgeRef) {
1307
+ this.logNudgeDelivery("fallback-missing-generation", currentRecord, `nudge dispatch fallback=root-session-handler cause=generation-provenance-unavailable root=${currentRecord.canonicalRoot}`);
1308
+ }
1309
+ this.onBgEventsNudge(currentRecord.identity.project_root, currentRecord.identity.session);
1310
+ delivered = true;
1311
+ }
1312
+ if (delivered)
1161
1313
  return;
1162
- this.onBgEventsNudge?.(identity.project_root, identity.session);
1163
- if (nudgeRef)
1164
- this.onBgEventsNudgeRef?.(nudgeRef);
1314
+ this.logNudgeDelivery("drop-no-compatible-handler", currentRecord, `nudge dropped cause=generation-provenance-unavailable-and-root-session-handler-unwired root=${currentRecord.canonicalRoot}`);
1165
1315
  };
1166
1316
  let sub = null;
1167
1317
  const clearDormantSubscription = () => {
@@ -1173,7 +1323,7 @@ export class SubcTransportPool {
1173
1323
  return false;
1174
1324
  this.markRootDormant(record.canonicalRoot, error);
1175
1325
  return true;
1176
- }, clearDormantSubscription, nudgeRef, () => this.isCurrentSession(record.identityKey, record) &&
1326
+ }, clearDormantSubscription, this.bgDispatchProbeIntervalMs, nudgeRef, () => this.isCurrentSession(record.identityKey, record) &&
1177
1327
  this.isCurrentLiveGeneration(record.canonicalRoot, record.generation));
1178
1328
  record.bgSub = sub;
1179
1329
  if (!this.rootCanAttach(record.canonicalRoot)) {
@@ -1250,8 +1400,25 @@ export class SubcTransportPool {
1250
1400
  return Promise.resolve();
1251
1401
  return registry.requestProjectRootClose(registration.concretePoolId, root, generation, cause);
1252
1402
  }
1253
- /** No-op over subc: config is read locally by AFT (wire tiers are ignored). */
1254
- setConfigureOverride(_key, _value) { }
1403
+ /**
1404
+ * Subc reads config locally, but plugin registration facts are process state
1405
+ * and must accompany route requests because RouteBind has no such field.
1406
+ */
1407
+ setConfigureOverride(key, value) {
1408
+ if (key !== "edit_slot_survives")
1409
+ return;
1410
+ if (typeof value !== "boolean") {
1411
+ throw new Error("edit_slot_survives must be set once to a boolean");
1412
+ }
1413
+ if (this.editSlotSurvivesCaptured) {
1414
+ throw new Error("edit_slot_survives is write-once and was already captured");
1415
+ }
1416
+ this.editSlotSurvives = value;
1417
+ this.editSlotSurvivesCaptured = true;
1418
+ }
1419
+ getEditSlotSurvives() {
1420
+ return this.editSlotSurvives;
1421
+ }
1255
1422
  /** No-op over subc: the daemon owns the live module's configure lifecycle. */
1256
1423
  async reconfigure(_projectRoot, _overrides) { }
1257
1424
  /** No-op over subc: the daemon supervises the binary, not the plugin. */