@parall/agent-core 1.44.0 → 1.46.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 (62) hide show
  1. package/dist/channel-capability.d.ts +2 -0
  2. package/dist/channel-capability.d.ts.map +1 -1
  3. package/dist/channel-capability.js +15 -0
  4. package/dist/dispatch-adapter.d.ts +9 -0
  5. package/dist/dispatch-adapter.d.ts.map +1 -1
  6. package/dist/event-format.d.ts.map +1 -1
  7. package/dist/event-format.js +27 -7
  8. package/dist/fork-session-finalizer.d.ts +65 -0
  9. package/dist/fork-session-finalizer.d.ts.map +1 -0
  10. package/dist/fork-session-finalizer.js +70 -0
  11. package/dist/gateway-base.d.ts +55 -0
  12. package/dist/gateway-base.d.ts.map +1 -1
  13. package/dist/gateway-base.js +640 -263
  14. package/dist/gateway-lane-flow.d.ts +75 -5
  15. package/dist/gateway-lane-flow.d.ts.map +1 -1
  16. package/dist/gateway-lane-flow.js +240 -18
  17. package/dist/http-keepalive.d.ts +4 -0
  18. package/dist/http-keepalive.d.ts.map +1 -0
  19. package/dist/http-keepalive.js +33 -0
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +7 -1
  23. package/dist/lane-ledger.d.ts +8 -0
  24. package/dist/lane-ledger.d.ts.map +1 -1
  25. package/dist/lane-ledger.js +14 -0
  26. package/dist/session-lifecycle.d.ts +198 -0
  27. package/dist/session-lifecycle.d.ts.map +1 -0
  28. package/dist/session-lifecycle.js +446 -0
  29. package/dist/skills/parall-clips.d.ts +1 -1
  30. package/dist/skills/parall-clips.d.ts.map +1 -1
  31. package/dist/skills/parall-clips.js +3 -0
  32. package/dist/skills/parall-schedules.d.ts +1 -1
  33. package/dist/skills/parall-schedules.d.ts.map +1 -1
  34. package/dist/skills/parall-schedules.js +1 -1
  35. package/dist/skills/parall-tasks.d.ts +1 -1
  36. package/dist/skills/parall-tasks.d.ts.map +1 -1
  37. package/dist/skills/parall-tasks.js +21 -5
  38. package/dist/step-persister.d.ts +66 -0
  39. package/dist/step-persister.d.ts.map +1 -0
  40. package/dist/step-persister.js +116 -0
  41. package/dist/step-retry-queue.d.ts +91 -0
  42. package/dist/step-retry-queue.d.ts.map +1 -0
  43. package/dist/step-retry-queue.js +259 -0
  44. package/dist/types.d.ts +1 -1
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +3 -2
  47. package/src/channel-capability.ts +16 -0
  48. package/src/dispatch-adapter.ts +10 -0
  49. package/src/event-format.ts +27 -7
  50. package/src/fork-session-finalizer.ts +122 -0
  51. package/src/gateway-base.ts +747 -331
  52. package/src/gateway-lane-flow.ts +275 -18
  53. package/src/http-keepalive.ts +36 -0
  54. package/src/index.ts +7 -1
  55. package/src/lane-ledger.ts +14 -0
  56. package/src/session-lifecycle.ts +552 -0
  57. package/src/skills/parall-clips.ts +3 -0
  58. package/src/skills/parall-schedules.ts +1 -1
  59. package/src/skills/parall-tasks.ts +21 -5
  60. package/src/step-persister.ts +161 -0
  61. package/src/step-retry-queue.ts +296 -0
  62. package/src/types.ts +2 -1
@@ -1,3 +1,4 @@
1
+ import { ApiError } from '@parall/sdk';
1
2
  import type { ParallClient } from '@parall/sdk';
2
3
  import type { DispatchAdapter, GatewayLogger } from './dispatch-adapter.js';
3
4
  import type { DispatchableMessage, MessageDispatchDecision } from './gateway-base.js';
@@ -16,8 +17,15 @@ import type { ParallEvent } from './types.js';
16
17
  export interface LaneFlowHost {
17
18
  laneLedger?: LaneLedger;
18
19
  ledgerDisabled: boolean;
20
+ /**
21
+ * Sticky: the server answered a by-id complete with 400 (predates the
22
+ * form, v1.44). Typed resolution falls back to the legacy ack for the
23
+ * rest of the process; claims stay on the ledger (that face is older).
24
+ */
25
+ typedByIdCompleteUnsupported: boolean;
19
26
  shuttingDown: boolean;
20
27
  dispatchedMessages: Set<string>;
28
+ dispatchedTasks: Set<string>;
21
29
  /**
22
30
  * Per-WorkItem failure backoff for typed dispatch consumption. A consume
23
31
  * that ends without an ack re-arms the entry; the next attempt for the
@@ -173,19 +181,208 @@ export async function dispatchLaneGroup(
173
181
  return 'dispatched';
174
182
  }
175
183
 
184
+ /**
185
+ * The WorkItem ids of a typed event group whose lifecycle the ledger owns
186
+ * (claimed into dsp lanes by consumeTypedDispatch), or null when the group
187
+ * must stay on the legacy received/ack surface — ledger unavailable, an id
188
+ * missing, or a member that rides a message lane. Non-null means: skip
189
+ * legacy received+ack, resolve via the by-id complete, and fold the
190
+ * turn-error signal into the outcome.
191
+ */
192
+ export function typedLedgerEventIds(host: LaneFlowHost, events: ParallEvent[]): string[] | null {
193
+ if (!host.laneLedger || host.ledgerDisabled) return null;
194
+ const ids: string[] = [];
195
+ for (const ev of events) {
196
+ if (!ev.dispatchEventId || host.usesLaneLedger(ev)) return null;
197
+ ids.push(ev.dispatchEventId);
198
+ }
199
+ return ids.length > 0 ? ids : null;
200
+ }
201
+
202
+ /** Outcome of a by-id complete attempt. */
203
+ export type TypedResolveOutcome = 'ok' | 'stale' | 'unsupported' | 'failed';
204
+
205
+ /**
206
+ * A pre-by-id server (v1.44) routes a by-id body into its lane-form
207
+ * validation and answers 400 INVALID_REQUEST. Any 400 here means the server
208
+ * did not understand the form — a well-formed by-id call never 400s on a
209
+ * current server — so the caller falls back to the legacy typed ack for the
210
+ * rest of the process (sticky; the bridge restarts on the next update).
211
+ */
212
+ function isByIDCompleteUnsupported(err: unknown): boolean {
213
+ return err instanceof ApiError && err.status === 400;
214
+ }
215
+
216
+ /**
217
+ * By-id complete (turn_outcome ok): terminal resolution of ONE WorkItem —
218
+ * exact where the source pair is ambiguous across task siblings. `lane`
219
+ * fences the call while the row is claim-owned (a dethroned caller gets
220
+ * 'stale' and must leave the row to its successor); omit it for the
221
+ * wrapper-less pending close (buffered drain, administrative drops).
222
+ */
223
+ export async function resolveDispatchByID(
224
+ host: LaneFlowHost,
225
+ dispatchEventId: string,
226
+ lane?: string,
227
+ ): Promise<TypedResolveOutcome> {
228
+ try {
229
+ await host.opts.client.completeDispatch(host.opts.config.org_id, {
230
+ dispatch_event_id: dispatchEventId,
231
+ ...(lane ? { lane } : {}),
232
+ turn_outcome: 'ok',
233
+ });
234
+ return 'ok';
235
+ } catch (err) {
236
+ if (err instanceof ApiError && err.status === 409) return 'stale';
237
+ if (isByIDCompleteUnsupported(err)) return 'unsupported';
238
+ host.opts.log?.warn(
239
+ `by-id complete failed for ${dispatchEventId} — leaving for re-drive: ${String(err)}`,
240
+ );
241
+ return 'failed';
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Free a typed event's in-memory hot-path dedupe claim by its source pair —
247
+ * the ParallEvent-keyed mirror of the gateway's clearTypedDispatchDedupe
248
+ * (which keys on the wire DispatchNewData). Used when a buffered typed
249
+ * dispatch ran but its resolution failed: the member re-drives, and a stale
250
+ * local claim would make this pod reject the retry forever.
251
+ *
252
+ * PARITY: the switch below and clearTypedDispatchDedupe's must handle the
253
+ * same typed source families — when adding a new typed event type, extend
254
+ * BOTH (they key the same dedupe entries from different event shapes).
255
+ */
256
+ export function clearTypedDedupeForEvent(host: LaneFlowHost, event: ParallEvent): void {
257
+ const sourceId = event.ackSourceId;
258
+ if (!sourceId) return;
259
+ switch (event.ackSourceType) {
260
+ case 'task_activity': {
261
+ // Task dedupe keys are `${task_id}:${updated_at}`; the task id is the
262
+ // event target. Prefix-clear mirrors clearTypedDispatchDedupe.
263
+ const prefix = `${event.targetId}:`;
264
+ for (const key of host.dispatchedTasks) {
265
+ if (key.startsWith(prefix)) host.dispatchedTasks.delete(key);
266
+ }
267
+ break;
268
+ }
269
+ case 'comment':
270
+ host.dispatchedTasks.delete(`comment:${sourceId}`);
271
+ break;
272
+ case 'schedule_run':
273
+ host.dispatchedTasks.delete(`schedule_run:${sourceId}`);
274
+ break;
275
+ case 'external_trigger_run':
276
+ host.dispatchedTasks.delete(`external_trigger_run:${sourceId}`);
277
+ break;
278
+ case 'channel_message':
279
+ host.dispatchedMessages.delete(`channel_message:${sourceId}`);
280
+ break;
281
+ case 'approval':
282
+ host.dispatchedTasks.delete(`approval:${sourceId}`);
283
+ break;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Settle a drained typed group — the one wrapper-less dispatch path: these
289
+ * events buffered behind a busy main (fork-less adapter), their consume
290
+ * guards returned false and released the claims, so the drain site owns the
291
+ * terminal resolution. Rows are pending — the by-id close resolves them
292
+ * without a fence; a row a successor lane meanwhile claimed answers stale and
293
+ * is that owner's to finish. `turnErrored` (consumed at the call site before
294
+ * the drain can start another turn) leaves the rows for the renotify pacing
295
+ * instead. Either way a row left live gets its local dedupe claim freed, or
296
+ * this pod would reject the retry forever.
297
+ */
298
+ export async function settleDrainedTypedGroup(
299
+ host: LaneFlowHost,
300
+ events: ParallEvent[],
301
+ ids: string[],
302
+ turnErrored: boolean,
303
+ ): Promise<void> {
304
+ if (turnErrored) {
305
+ host.opts.log?.info(
306
+ `buffered typed turn for ${events[events.length - 1]?.messageId} surfaced a runtime error — leaving for re-drive`,
307
+ );
308
+ for (const event of events) clearTypedDedupeForEvent(host, event);
309
+ return;
310
+ }
311
+ // The legacy ack BY ID, never by source — task siblings share a source
312
+ // pair, and a by-source ack would sweep the undispatched one. Awaited, and
313
+ // the dedupe claim is freed on EVERY outcome: a claim's lifetime is the
314
+ // buffer stay (#1149). On failure the row is still pending and its retry
315
+ // must not be self-rejected; on success the row is terminal — but a
316
+ // shared-key sibling WorkItem (task assign+update fetch the same
317
+ // `task:updated_at` pair) must not be rejected by a leftover claim either.
318
+ const legacyAckFrom = async (start: number) => {
319
+ for (const [j, id] of ids.slice(start).entries()) {
320
+ try {
321
+ await host.opts.client.ackDispatchByID(host.opts.config.org_id, id);
322
+ } catch {
323
+ // Transient ack failure: the row stays live server-side and its
324
+ // re-drive re-converges — keep settling the REST of the group (a
325
+ // propagated error would strand every later id's retained claim).
326
+ } finally {
327
+ clearTypedDedupeForEvent(host, events[start + j]);
328
+ }
329
+ }
330
+ };
331
+ if (host.typedByIdCompleteUnsupported) {
332
+ // Sticky: the server already answered one by-id probe with 400 — go
333
+ // straight to the legacy acks instead of re-probing per group.
334
+ await legacyAckFrom(0);
335
+ return;
336
+ }
337
+ for (const [i, id] of ids.entries()) {
338
+ const outcome = await resolveDispatchByID(host, id);
339
+ if (outcome === 'unsupported') {
340
+ host.typedByIdCompleteUnsupported = true;
341
+ host.opts.log?.warn(
342
+ 'server predates the by-id dispatch complete — falling back to legacy typed acks',
343
+ );
344
+ await legacyAckFrom(i);
345
+ return;
346
+ }
347
+ // Freed on every outcome — see legacyAckFrom's contract note.
348
+ clearTypedDedupeForEvent(host, events[i]);
349
+ }
350
+ }
351
+
176
352
  /** Failure backoff pacing for typed dispatch retries (base 2s, cap 5min). */
177
353
  const TYPED_BACKOFF_BASE_MS = 2_000;
178
354
  const TYPED_BACKOFF_CAP_MS = 5 * 60_000;
179
355
  const TYPED_BACKOFF_MAP_CAP = 512;
180
356
 
357
+ /** Consumption hooks for one typed dispatch. */
358
+ export interface TypedConsumeHooks {
359
+ /**
360
+ * Legacy administrative ack — invoked ONLY on the ledger-disabled fallback
361
+ * path (old server). On the ledger path the WorkItem resolves through the
362
+ * sources-form complete instead; this callback never fires there.
363
+ */
364
+ legacyAck: (dispatchEventId?: string) => boolean | void | Promise<boolean | void>;
365
+ /**
366
+ * Free the item's in-memory hot-path dedupe claim. Invoked when the handler
367
+ * ran but the resolution could not commit (sources-form complete failed) —
368
+ * the member is released for re-drive, and a stale local claim would make
369
+ * this pod reject its own retry forever.
370
+ */
371
+ clearDedupe?: () => void;
372
+ }
373
+
181
374
  /**
182
375
  * Consume one typed dispatch (task/comment/schedule/trigger/approval) under
183
376
  * its typed-lane occupancy guard: claim the dsp:<id> lane (skip when another
184
- * pod holds it or the WorkItem is already resolved), run the handler, ack on
185
- * success (the doc's option (b): notification delivered, tracked elsewhere),
186
- * then release the lane. Legacy run+ack flow when the ledger is unavailable.
377
+ * pod holds it or the WorkItem is already resolved), run the handler, then
378
+ * settle. A successful run resolves the WorkItem terminally via the
379
+ * sources-form complete (turn_outcome ok the server sweeps it, or lets a
380
+ * typed Effect committed during the turn stand, and drops the vacated lane);
381
+ * a failed / errored / unresolved run releases the member back to pending on
382
+ * the redrive budget via the lane complete. Legacy run+ack flow when the
383
+ * ledger is unavailable.
187
384
  *
188
- * Repeated failures back off: a consume that ends un-acked re-arms the
385
+ * Repeated failures back off: a consume that ends unresolved re-arms the
189
386
  * WorkItem's backoff entry, and the next attempt sleeps out the remaining
190
387
  * window before claiming. Without this, complete's release re-drives the
191
388
  * item instantly and a persistently-failing consume (e.g. buffered behind a
@@ -196,7 +393,7 @@ export async function consumeTypedDispatch(
196
393
  host: LaneFlowHost,
197
394
  ref: { dispatchEventId?: string; sourceType?: string; sourceId?: string },
198
395
  run: (dispatchEventId?: string) => Promise<boolean>,
199
- ack: (dispatchEventId?: string) => boolean | void | Promise<boolean | void>,
396
+ hooks: TypedConsumeHooks,
200
397
  ): Promise<void> {
201
398
  const backoffKey = ref.dispatchEventId ?? `${ref.sourceType}:${ref.sourceId}`;
202
399
  const armed = host.typedRedriveBackoff.get(backoffKey);
@@ -221,8 +418,8 @@ export async function consumeTypedDispatch(
221
418
  // consume — clearing backoff there would let an ack outage re-create the
222
419
  // wire-speed release/re-drive loop against a pre-budget server.
223
420
  const settleAck = (ackResult: boolean | void) => ackResult !== false;
224
- const settle = (acked: boolean) => {
225
- if (acked) {
421
+ const settle = (resolved: boolean) => {
422
+ if (resolved) {
226
423
  host.typedRedriveBackoff.delete(backoffKey);
227
424
  return;
228
425
  }
@@ -249,7 +446,7 @@ export async function consumeTypedDispatch(
249
446
  let acked = false;
250
447
  try {
251
448
  if (await run(ref.dispatchEventId)) {
252
- acked = settleAck(await ack(ref.dispatchEventId));
449
+ acked = settleAck(await hooks.legacyAck(ref.dispatchEventId));
253
450
  }
254
451
  } finally {
255
452
  settle(acked);
@@ -279,20 +476,65 @@ export async function consumeTypedDispatch(
279
476
  );
280
477
  return;
281
478
  }
282
- let acked = false;
479
+ let resolved = false;
480
+ let viaLegacyAck = false;
283
481
  try {
284
- // Ack must settle before Complete. A fire-and-forget ack races the lane
285
- // release: Complete can return the still-received typed item to pending
286
- // and publish a re-drive while its successful ack is still in flight.
482
+ // A successful run resolves the member terminally through the by-id
483
+ // complete one fenced call that sweeps exactly this WorkItem (a typed
484
+ // Effect committed during the turn already made it terminal idempotent
485
+ // no-op) AND drops the vacated lane. Resolution must settle inside this
486
+ // guard: a fire-and-forget resolve would race the finally's release and
487
+ // spuriously re-drive handled work.
287
488
  if (await run(lane.typedDispatchEventId)) {
288
- acked = settleAck(await ack(lane.typedDispatchEventId));
489
+ if (host.typedByIdCompleteUnsupported || !lane.typedDispatchEventId) {
490
+ // Pre-by-id server (or a defensive claim shape without the id):
491
+ // the legacy administrative ack is still live there.
492
+ viaLegacyAck = true;
493
+ resolved = settleAck(await hooks.legacyAck(lane.typedDispatchEventId));
494
+ } else {
495
+ const outcome = await resolveDispatchByID(host, lane.typedDispatchEventId, lane.lane);
496
+ if (outcome === 'unsupported') {
497
+ host.typedByIdCompleteUnsupported = true;
498
+ host.opts.log?.warn(
499
+ 'server predates the by-id dispatch complete — falling back to the legacy typed ack',
500
+ );
501
+ viaLegacyAck = true;
502
+ resolved = settleAck(await hooks.legacyAck(lane.typedDispatchEventId));
503
+ } else if (outcome === 'stale') {
504
+ // A successor lane owns the row — its turn resolves it. Nothing to
505
+ // spin on locally; our claim record is already dethroned. Free the
506
+ // local dedupe claim though: if the successor later FAILS and
507
+ // releases the row, the re-drive may land back on this pod, and a
508
+ // stale local claim would self-reject the retry into the budget.
509
+ hooks.clearDedupe?.();
510
+ resolved = true;
511
+ } else {
512
+ resolved = outcome === 'ok';
513
+ if (!resolved) {
514
+ // The member is about to be released for re-drive — free its
515
+ // hot-path dedupe claim first, or this pod rejects its own retry
516
+ // forever (same contract as the legacy ack-failure path).
517
+ hooks.clearDedupe?.();
518
+ }
519
+ }
520
+ }
289
521
  }
290
522
  } finally {
291
- settle(acked);
292
- // Release the occupancy row. A buffered dispatch may outlive this guard
293
- // (lane TTL) acceptable at-least-once; the ledger's resolution paths
294
- // still dedupe the persistent side effects.
295
- await host.laneLedger.completeIfIdle(lane.laneKey, false).catch(() => {});
523
+ settle(resolved);
524
+ if (resolved && !viaLegacyAck) {
525
+ // The by-id complete already dropped (or a takeover already owns) the
526
+ // server-side lane row; only the local record and context file remain.
527
+ host.laneLedger.dropLocal(lane.laneKey);
528
+ } else {
529
+ // Two jobs, one call: an unresolved turn (unconsumed / failed /
530
+ // errored / resolution-failed) releases the member back to pending on
531
+ // the redrive budget; a legacy-acked turn still needs its server-side
532
+ // lane row dropped (the ack resolves the row but not the occupancy).
533
+ // A buffered dispatch may outlive this guard (lane TTL) — acceptable
534
+ // at-least-once; the ledger's resolution paths still dedupe the
535
+ // persistent side effects.
536
+ await host.laneLedger.completeIfIdle(lane.laneKey, false).catch(() => {});
537
+ }
296
538
  }
297
539
  }
298
540
 
@@ -310,7 +552,22 @@ export async function consumeMessageWorkItem(
310
552
  ): Promise<void> {
311
553
  if (host.shuttingDown) return;
312
554
  if (!host.tryClaimMessage(item.source_id)) return;
555
+ // Administrative drop (deleted source / self-sender / skip decision): on
556
+ // the ledger path the by-id complete closes the pending row terminally and
557
+ // refuses (409) a row a live lane owns — the owner's turn resolves it. The
558
+ // legacy ack remains the ledger-disabled / pre-by-id fallback.
559
+ // Fire-and-forget either way: a failed drop leaves the row live and the
560
+ // next re-drive converges on the same drop.
313
561
  const ackItem = () => {
562
+ if (host.laneLedger && !host.ledgerDisabled && !host.typedByIdCompleteUnsupported) {
563
+ void resolveDispatchByID(host, item.id).then((outcome) => {
564
+ if (outcome === 'unsupported') {
565
+ host.typedByIdCompleteUnsupported = true;
566
+ host.opts.client.ackDispatchByID(host.opts.config.org_id, item.id).catch(() => {});
567
+ }
568
+ });
569
+ return;
570
+ }
314
571
  host.opts.client.ackDispatchByID(host.opts.config.org_id, item.id).catch(() => {});
315
572
  };
316
573
 
@@ -0,0 +1,36 @@
1
+ import { Agent, setGlobalDispatcher } from 'undici';
2
+
3
+ /**
4
+ * Keep bridge→api HTTP connections long-lived.
5
+ *
6
+ * Node's built-in fetch closes idle pooled connections after ~4s when the
7
+ * server sends no Keep-Alive hint (our NLB+Envoy edge sends none), so any two
8
+ * bridge API calls spaced more than a few seconds apart pay a fresh TCP+TLS
9
+ * handshake. On a degraded long-haul link that handshake is exactly what
10
+ * fails — established connections (the WS, the runtime's own model stream)
11
+ * ride through such windows while every bridge call times out (2026-07-10:
12
+ * step creation losses). A 2-minute idle timeout keeps one warm connection
13
+ * across an active turn's step cadence, comfortably below the NLB's 350s
14
+ * idle cutoff.
15
+ *
16
+ * Uses undici's global-dispatcher registry, which Node's built-in fetch
17
+ * shares (verified on Node 22 / undici 7) — so this covers every fetch in
18
+ * the process: ParallClient, lane-ledger heartbeats, OTLP export.
19
+ * Node-only; call once from a bridge/daemon entrypoint before any fetch.
20
+ */
21
+ let installed = false;
22
+
23
+ export function configureHttpKeepAlive(opts?: { keepAliveTimeoutMs?: number }): void {
24
+ // Idempotent: a second call (e.g. a process hosting several bridge
25
+ // entrypoints) must not replace — and leak — the already-installed Agent.
26
+ if (installed) return;
27
+ installed = true;
28
+ const keepAliveTimeout = opts?.keepAliveTimeoutMs ?? 120_000;
29
+ setGlobalDispatcher(
30
+ new Agent({
31
+ keepAliveTimeout,
32
+ // Also cap the server-hinted value: never idle past the edge's cutoff.
33
+ keepAliveMaxTimeout: keepAliveTimeout,
34
+ }),
35
+ );
36
+ }
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@ export * from './provider-config.js';
2
2
  export * from './types.js';
3
3
  export * from './lane-key.js';
4
4
  export type { LaneFlowHost } from './gateway-lane-flow.js';
5
- export { consumeTypedDispatch } from './gateway-lane-flow.js';
5
+ export { consumeTypedDispatch, settleDrainedTypedGroup } from './gateway-lane-flow.js';
6
6
  export * from './session-state.js';
7
7
  export * from './routing.js';
8
8
  export * from './event-format.js';
@@ -11,6 +11,12 @@ export * from './bridge-workspace.js';
11
11
  export * from './dispatch-adapter.js';
12
12
  export { createLogger, childLogger } from './logger.js';
13
13
  export * from './gateway-base.js';
14
+ export { configureHttpKeepAlive } from './http-keepalive.js';
15
+ // StepPersister / StepRetryQueue / SessionLifecycleCoordinator /
16
+ // ForkSessionFinalizer are internal gateway collaborators — deliberately NOT
17
+ // re-exported from the package root (no external consumer; a root export
18
+ // would become an accidental long-term compat promise). Tests import their
19
+ // built modules directly (e.g. `../dist/step-retry-queue.js`).
14
20
  export * from './platform-config.js';
15
21
  export * from './channel-capability.js';
16
22
  export * from './channel-token.js';
@@ -393,6 +393,20 @@ export class LaneLedger {
393
393
  return lane;
394
394
  }
395
395
 
396
+ /**
397
+ * Drop a lane's local record without any server call — for a typed lane
398
+ * whose member the by-id complete just resolved (the server dropped the
399
+ * vacated lane row in the same transaction). Calling completeIfIdle
400
+ * instead would fire a lane-form Complete at a lane that no longer exists
401
+ * and burn an RPC on the guaranteed STALE_LANE answer.
402
+ */
403
+ dropLocal(laneKey: string): void {
404
+ const lane = this.lanes.get(laneKey);
405
+ if (!lane) return;
406
+ this.lanes.delete(laneKey);
407
+ this.removeLaneContext(lane);
408
+ }
409
+
396
410
  /**
397
411
  * Remove the per-lane context file (and its CLI sidecar) when the lane
398
412
  * ends. A leftover file would make a later cross-context send to the same