@parall/agent-core 1.42.1 → 1.44.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 (49) hide show
  1. package/dist/bin/channel-exec.d.ts +4 -0
  2. package/dist/bin/channel-exec.d.ts.map +1 -0
  3. package/dist/bin/channel-exec.js +246 -0
  4. package/dist/channel-capability.d.ts +16 -0
  5. package/dist/channel-capability.d.ts.map +1 -0
  6. package/dist/channel-capability.js +155 -0
  7. package/dist/channel-token.d.ts +19 -0
  8. package/dist/channel-token.d.ts.map +1 -0
  9. package/dist/channel-token.js +73 -0
  10. package/dist/event-format.d.ts.map +1 -1
  11. package/dist/event-format.js +21 -16
  12. package/dist/gateway-base.d.ts +37 -0
  13. package/dist/gateway-base.d.ts.map +1 -1
  14. package/dist/gateway-base.js +120 -12
  15. package/dist/gateway-lane-flow.d.ts +23 -2
  16. package/dist/gateway-lane-flow.d.ts.map +1 -1
  17. package/dist/gateway-lane-flow.js +118 -6
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +3 -0
  21. package/dist/lane-ledger.d.ts +22 -0
  22. package/dist/lane-ledger.d.ts.map +1 -1
  23. package/dist/lane-ledger.js +36 -1
  24. package/dist/platform-config.d.ts +15 -0
  25. package/dist/platform-config.d.ts.map +1 -1
  26. package/dist/platform-config.js +28 -0
  27. package/dist/prompt-fragments.d.ts +1 -1
  28. package/dist/prompt-fragments.d.ts.map +1 -1
  29. package/dist/prompt-fragments.js +29 -7
  30. package/dist/skills/index.js +1 -1
  31. package/dist/skills/parall-platform.d.ts +1 -1
  32. package/dist/skills/parall-platform.d.ts.map +1 -1
  33. package/dist/skills/parall-platform.js +23 -4
  34. package/dist/types.d.ts +5 -1
  35. package/dist/types.d.ts.map +1 -1
  36. package/package.json +2 -2
  37. package/src/bin/channel-exec.ts +262 -0
  38. package/src/channel-capability.ts +187 -0
  39. package/src/channel-token.ts +92 -0
  40. package/src/event-format.ts +21 -16
  41. package/src/gateway-base.ts +141 -19
  42. package/src/gateway-lane-flow.ts +132 -5
  43. package/src/index.ts +3 -0
  44. package/src/lane-ledger.ts +46 -3
  45. package/src/platform-config.ts +44 -0
  46. package/src/prompt-fragments.ts +29 -7
  47. package/src/skills/index.ts +1 -1
  48. package/src/skills/parall-platform.ts +23 -4
  49. package/src/types.ts +5 -1
@@ -148,6 +148,14 @@ export type ParallGatewayOptions = {
148
148
  * this directory. Absent → legacy received/ack flow (openclaw / hermes).
149
149
  */
150
150
  dispatchContextDir?: string;
151
+ /**
152
+ * Live view of the agent's platform-granted capability keys (bridges wire
153
+ * PlatformConfigManager.capabilities().map(c => c.key)). Read at
154
+ * channel-event build time so the reply hint routes to the capability
155
+ * affordance (e.g. feishu-cli → lark-cli) — the single outbound path.
156
+ * Absent/empty → the hint states outbound is disabled.
157
+ */
158
+ getCapabilityKeys?: () => string[];
151
159
  onConfigUpdate?: (data: AgentConfigUpdateData) => Promise<void> | void;
152
160
  onSessionReady?: (state: {
153
161
  activeSessionId?: string;
@@ -293,6 +301,9 @@ export class ParallAgentGateway {
293
301
  // (stable mapping; avoids one connection fetch per inbound message).
294
302
  private readonly channelConnectionProviders = new Map<string, string>();
295
303
  private readonly dispatchedMessages = new Set<string>();
304
+ // Per-WorkItem failure backoff for typed dispatch consumption — see
305
+ // LaneFlowHost.typedRedriveBackoff in gateway-lane-flow.ts.
306
+ readonly typedRedriveBackoff = new Map<string, { failures: number; until: number }>();
296
307
  private readonly forkStates = new Map<string, ActiveForkState>();
297
308
  private readonly dispatchState: DispatchState = {
298
309
  mainDispatching: false,
@@ -448,8 +459,17 @@ export class ParallAgentGateway {
448
459
  source_id: data.id,
449
460
  })
450
461
  .then(
451
- () => undefined,
452
- () => undefined,
462
+ () => true,
463
+ (err) => {
464
+ // Same contract as ackDispatchEvent: a failed ack is a
465
+ // failed consume (arms backoff) and must free the hot-path
466
+ // dedupe so the re-drive isn't rejected by this pod forever.
467
+ this.dispatchedTasks.delete(`${data.id}:${data.updated_at}`);
468
+ this.opts.log?.warn(
469
+ `dispatch ack failed for task ${data.id}, releasing for re-drive: ${String(err)}`,
470
+ );
471
+ return false;
472
+ },
453
473
  );
454
474
  },
455
475
  );
@@ -621,6 +641,45 @@ export class ParallAgentGateway {
621
641
  return true;
622
642
  }
623
643
 
644
+ /**
645
+ * Lane currently being dispatched per session — lets external activity
646
+ * signals renew exactly the caller's lane (renewing all lanes would keep
647
+ * an unrelated stalled fork's lane leased forever).
648
+ */
649
+ private sessionActiveLanes = new Map<string, string>();
650
+
651
+ noteSessionLane(sessionKey: string, laneKey: string | null): void {
652
+ if (laneKey == null) this.sessionActiveLanes.delete(sessionKey);
653
+ else this.sessionActiveLanes.set(sessionKey, laneKey);
654
+ }
655
+
656
+ /**
657
+ * External runtime-activity signal for adapters whose tool activity does
658
+ * not flow through the RuntimeEvent stream (openclaw hooks call this from
659
+ * the tool-call lifecycle): renews the session's OWN active ledger lane so
660
+ * a long tool call cannot outlive the lease and get dethroned mid-turn.
661
+ * No-op without an active ledger lane for the session.
662
+ */
663
+ touchRuntimeActivity(sessionKey: string): void {
664
+ if (this.ledgerDisabled) return;
665
+ const laneKey = this.sessionActiveLanes.get(sessionKey);
666
+ if (laneKey) this.laneLedger?.renewByKey(laneKey);
667
+ }
668
+
669
+ /** Sessions whose in-flight turn surfaced a runtime error event. */
670
+ private turnErrorSessions = new Set<string>();
671
+
672
+ /**
673
+ * Consume (read-and-clear) the error marker for sessionKey's last turn.
674
+ * Feeds complete's turn_outcome so an error turn's lane members are
675
+ * released for retry instead of no_action-swept (design §3). Consuming
676
+ * (rather than peeking) keeps one-shot fork session keys from accumulating
677
+ * in the set forever.
678
+ */
679
+ consumeTurnError(sessionKey: string): boolean {
680
+ return this.turnErrorSessions.delete(sessionKey);
681
+ }
682
+
624
683
  private async emitDispatchReceived(event: ParallEvent): Promise<void> {
625
684
  const sourceType = event.ackSourceType ?? (event.type === 'task' ? 'task_activity' : 'message');
626
685
  const sourceId = event.ackSourceId ?? event.messageId;
@@ -673,24 +732,27 @@ export class ParallAgentGateway {
673
732
  earlier: ParallEvent[];
674
733
  captureText?: string[];
675
734
  hasMoreLocal: () => boolean;
676
- }): Promise<'dispatched' | 'foreign' | 'shutdown'> {
735
+ }): Promise<'dispatched' | 'foreign' | 'shutdown' | 'failed'> {
677
736
  return dispatchLaneGroup(this.laneFlowHost(), opts);
678
737
  }
679
738
 
680
739
  private consumeTypedDispatch(
681
740
  ref: { dispatchEventId?: string; sourceType?: string; sourceId?: string },
682
741
  run: (dispatchEventId?: string) => Promise<boolean>,
683
- ack: (dispatchEventId?: string) => void | Promise<void>,
742
+ ack: (dispatchEventId?: string) => boolean | void | Promise<boolean | void>,
684
743
  ): Promise<void> {
685
744
  return consumeTypedDispatch(this.laneFlowHost(), ref, run, ack);
686
745
  }
687
746
 
688
747
  // Typed completion must wait until the administrative ack has either
689
748
  // committed or failed. Errors stay best-effort: a failed ack leaves the row
690
- // received, so Complete releases and re-drives it safely.
691
- private ackDispatchEvent(dispatchEventId: string, onFailure?: () => void): Promise<void> {
749
+ // received, so Complete releases and re-drives it safely. The boolean
750
+ // outcome feeds the typed-consume backoff an ack that failed must count
751
+ // as a failed consume, or an ack outage would clear the backoff entry and
752
+ // let the release re-drive spin at wire speed.
753
+ private ackDispatchEvent(dispatchEventId: string, onFailure?: () => void): Promise<boolean> {
692
754
  return this.opts.client.ackDispatchByID(this.opts.config.org_id, dispatchEventId).then(
693
- () => undefined,
755
+ () => true,
694
756
  (err) => {
695
757
  // Complete will return the still-received item to pending and publish
696
758
  // an immediate hint. Free its hot-path claim first, otherwise that
@@ -699,6 +761,7 @@ export class ParallAgentGateway {
699
761
  this.opts.log?.warn(
700
762
  `dispatch ack failed for ${dispatchEventId}, releasing for re-drive: ${String(err)}`,
701
763
  );
764
+ return false;
702
765
  },
703
766
  );
704
767
  }
@@ -1113,6 +1176,7 @@ export class ParallAgentGateway {
1113
1176
  }
1114
1177
 
1115
1178
  resetDispatchMetrics(sessionKey);
1179
+ this.turnErrorSessions.delete(sessionKey);
1116
1180
  return runWithSessionKey(sessionKey, async () => {
1117
1181
  let dispatchSpan: ReturnType<typeof startDispatchSpan> = null;
1118
1182
 
@@ -1275,6 +1339,9 @@ export class ParallAgentGateway {
1275
1339
  ) {
1276
1340
  recordMessageSend(sessionKey, !runtimeEvent.error);
1277
1341
  }
1342
+ if (runtimeEvent.type === 'error') {
1343
+ this.turnErrorSessions.add(sessionKey);
1344
+ }
1278
1345
  await this.createRuntimeStep(
1279
1346
  binding.agentSessionId,
1280
1347
  event,
@@ -1471,13 +1538,29 @@ export class ParallAgentGateway {
1471
1538
  body: buildForkScopePrefix(last) + buildEventBody(last),
1472
1539
  earlier,
1473
1540
  captureText: batchText,
1474
- hasMoreLocal: () => fork.queue.length > 0,
1541
+ // Per-LANE residue check (parity with the main-buffer path):
1542
+ // the fork queue can hold several lanes (channel + thread of
1543
+ // the same chat). A whole-queue check would defer THIS lane's
1544
+ // complete behind another lane's items and never revisit it —
1545
+ // its members would sit received until lease expiry.
1546
+ hasMoreLocal: () =>
1547
+ fork.queue.some(
1548
+ (it) => this.dispatchGroupKey(it.event) === this.dispatchGroupKey(last),
1549
+ ),
1475
1550
  });
1476
1551
  if (outcome === 'foreign') {
1477
1552
  // Another pod owns the lane — the events stay pending server-side.
1478
1553
  for (const item of items) item.resolve(false);
1479
1554
  break;
1480
1555
  }
1556
+ if (outcome === 'failed') {
1557
+ // Error turn: complete(error) already released the members for
1558
+ // redelivery. Do NOT push them to processedEvents — the redrive
1559
+ // must not arrive wearing an "already handled" prefix. Keep
1560
+ // draining; the released work rejoins the next claim.
1561
+ for (const item of items) item.resolve(false);
1562
+ continue;
1563
+ }
1481
1564
  dispatched = outcome === 'dispatched';
1482
1565
  } else {
1483
1566
  dispatched = await this.runDispatch(
@@ -1518,6 +1601,10 @@ export class ParallAgentGateway {
1518
1601
  remaining.resolve(false);
1519
1602
  }
1520
1603
  } finally {
1604
+ // One-shot fork keys are never dispatched again — drop any error marker
1605
+ // the legacy (non-ledger) path left unconsumed, or the set grows with
1606
+ // every failed ephemeral fork.
1607
+ this.turnErrorSessions.delete(fork.fork.sessionKey);
1521
1608
  if (fork.deadlineTimer) {
1522
1609
  clearTimeout(fork.deadlineTimer);
1523
1610
  fork.deadlineTimer = null;
@@ -1630,7 +1717,7 @@ export class ParallAgentGateway {
1630
1717
  this.opts.runtimeKey,
1631
1718
  );
1632
1719
  if (this.usesLaneLedger(event)) {
1633
- let outcome: 'dispatched' | 'foreign' | 'shutdown';
1720
+ let outcome: 'dispatched' | 'foreign' | 'shutdown' | 'failed';
1634
1721
  try {
1635
1722
  outcome = await this.dispatchLaneGroup({
1636
1723
  events,
@@ -1657,6 +1744,13 @@ export class ParallAgentGateway {
1657
1744
  this.dispatchState.pendingForkResults.unshift(...pendingFork);
1658
1745
  continue;
1659
1746
  }
1747
+ if (outcome === 'failed') {
1748
+ // Error turn settled with complete(error) — members released for
1749
+ // redelivery. The fork results were not consumed by the failed
1750
+ // turn; keep them for the next real dispatch.
1751
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1752
+ continue;
1753
+ }
1660
1754
  // Dispatched — resolution happened server-side (reply cover or
1661
1755
  // no_action sweep); no legacy acks.
1662
1756
  continue;
@@ -1745,7 +1839,7 @@ export class ParallAgentGateway {
1745
1839
  if (this.usesLaneLedger(event)) {
1746
1840
  // Ledger flow: claim replaces mark-received; complete/reply replace
1747
1841
  // acks. A foreign incumbent leaves the event pending for re-drive.
1748
- let outcome: 'dispatched' | 'foreign' | 'shutdown' = 'shutdown';
1842
+ let outcome: 'dispatched' | 'foreign' | 'shutdown' | 'failed' = 'shutdown';
1749
1843
  try {
1750
1844
  try {
1751
1845
  outcome = await this.dispatchLaneGroup({
@@ -1816,10 +1910,17 @@ export class ParallAgentGateway {
1816
1910
  // fold leaves the event buffered — the drain claims it as its own
1817
1911
  // turn. Injection requires an exact lane match (same chat AND same
1818
1912
  // thread) — a thread message never rides a channel turn.
1913
+ // The adapter-capability check comes BEFORE the fold: on a
1914
+ // buffer-only adapter (openclaw — no enqueueDuringDispatch) a
1915
+ // folded-but-never-injected member would be broad-covered by the
1916
+ // current turn's reply without the model ever seeing it. Leaving
1917
+ // the event un-folded keeps it buffered; the drain claims it as
1918
+ // its own turn and folds it there.
1819
1919
  if (
1820
1920
  this.mainCurrentGroupKey === this.dispatchGroupKey(event) &&
1921
+ this.opts.dispatchAdapter.enqueueDuringDispatch != null &&
1821
1922
  (await this.laneLedger?.steerLive(event)) &&
1822
- (await this.opts.dispatchAdapter.enqueueDuringDispatch?.(
1923
+ (await this.opts.dispatchAdapter.enqueueDuringDispatch(
1823
1924
  this.opts.runtimeKey,
1824
1925
  buildEventBody(event),
1825
1926
  ))
@@ -1868,14 +1969,21 @@ export class ParallAgentGateway {
1868
1969
  return false;
1869
1970
  }
1870
1971
 
1871
- try {
1872
- await this.emitDispatchReceived(event);
1873
- } catch (err) {
1874
- this.opts.log?.warn?.(
1875
- `mark-received failed for fork dispatch, leaving unacked for retry: ${String(err)}`,
1876
- );
1877
- this.dispatchState.mainBuffer.push(event);
1878
- return false;
1972
+ // Ledger-managed events must never touch the legacy received surface
1973
+ // — the fork's lane claim marks them received. A mark-received here
1974
+ // outruns the claim, strands the row ownerless, and the chat goes
1975
+ // silent (the 0713 black hole; dispatch-convergence-design.md §6).
1976
+ // This was the only unguarded call site of the three.
1977
+ if (!this.usesLaneLedger(event)) {
1978
+ try {
1979
+ await this.emitDispatchReceived(event);
1980
+ } catch (err) {
1981
+ this.opts.log?.warn?.(
1982
+ `mark-received failed for fork dispatch, leaving unacked for retry: ${String(err)}`,
1983
+ );
1984
+ this.dispatchState.mainBuffer.push(event);
1985
+ return false;
1986
+ }
1879
1987
  }
1880
1988
  const fork = await this.opts.dispatchAdapter.forkSession({
1881
1989
  sessionKey: this.opts.runtimeKey,
@@ -2471,6 +2579,19 @@ export class ParallAgentGateway {
2471
2579
  }
2472
2580
  }
2473
2581
 
2582
+ // The reply hint routes on the live capability grant: `<provider>-cli`
2583
+ // present → the vendor CLI is on PATH (broker shim) and is THE reply
2584
+ // path; absent → outbound is disabled for this org (flag/connection off)
2585
+ // and the hint must say so instead of pointing at a retired clip. The
2586
+ // provider label lookup above is best-effort/cosmetic — when it fails,
2587
+ // ANY granted `*-cli` capability keeps the hint on the CLI path: a
2588
+ // transient metadata miss must not flip an actively granted agent's
2589
+ // hint to "outbound disabled" and strand a valid external message.
2590
+ const keys = this.opts.getCapabilityKeys?.() ?? [];
2591
+ const cliCapable = provider
2592
+ ? keys.includes(`${provider}-cli`)
2593
+ : keys.some((k) => k.endsWith('-cli'));
2594
+
2474
2595
  const event: ParallEvent = {
2475
2596
  type: 'channel_message',
2476
2597
  targetId: conv.id,
@@ -2485,6 +2606,7 @@ export class ParallAgentGateway {
2485
2606
  channelConversationType: conv.conversation_type || undefined,
2486
2607
  channelExternalConversationId: conv.external_conversation_id,
2487
2608
  channelExternalMessageId: msg.external_message_id,
2609
+ channelCliCapable: cliCapable,
2488
2610
  ackSourceType: 'channel_message',
2489
2611
  ackSourceId: msg.id,
2490
2612
  };
@@ -18,6 +18,15 @@ export interface LaneFlowHost {
18
18
  ledgerDisabled: boolean;
19
19
  shuttingDown: boolean;
20
20
  dispatchedMessages: Set<string>;
21
+ /**
22
+ * Per-WorkItem failure backoff for typed dispatch consumption. A consume
23
+ * that ends without an ack re-arms the entry; the next attempt for the
24
+ * same WorkItem is delayed (not skipped — a skipped attempt would strand
25
+ * the pending row until reconnect catch-up) so a claim→fail→complete
26
+ * tight loop is throttled to exponential intervals instead of spinning at
27
+ * wire speed against the server's immediate re-drive (2026-07-11 OOM).
28
+ */
29
+ typedRedriveBackoff: Map<string, { failures: number; until: number }>;
21
30
  opts: {
22
31
  client: ParallClient;
23
32
  log?: GatewayLogger;
@@ -28,6 +37,8 @@ export interface LaneFlowHost {
28
37
  disableLedger(reason: string): void;
29
38
  usesLaneLedger(event: ParallEvent): boolean;
30
39
  emitDispatchReceived(event: ParallEvent): Promise<void>;
40
+ consumeTurnError(sessionKey: string): boolean;
41
+ noteSessionLane(sessionKey: string, laneKey: string | null): void;
31
42
  runDispatch(
32
43
  event: ParallEvent,
33
44
  sessionKey: string,
@@ -59,7 +70,7 @@ export async function dispatchLaneGroup(
59
70
  captureText?: string[];
60
71
  hasMoreLocal: () => boolean;
61
72
  },
62
- ): Promise<'dispatched' | 'foreign' | 'shutdown'> {
73
+ ): Promise<'dispatched' | 'foreign' | 'shutdown' | 'failed'> {
63
74
  const ledger = host.laneLedger!;
64
75
  const event = opts.events[opts.events.length - 1];
65
76
  let lane: Awaited<ReturnType<LaneLedger['ensureLane']>>;
@@ -97,6 +108,9 @@ export async function dispatchLaneGroup(
97
108
  }
98
109
  return 'foreign';
99
110
  }
111
+ // Register the session's active lane so external activity signals
112
+ // (touchRuntimeActivity from adapter hooks) renew exactly this lane.
113
+ host.noteSessionLane(opts.sessionKey, lane.laneKey);
100
114
  let dispatched = false;
101
115
  try {
102
116
  dispatched = await host.runDispatch(
@@ -109,34 +123,141 @@ export async function dispatchLaneGroup(
109
123
  } catch (err) {
110
124
  // Failed turn: hand the members back so the retry (this pod or the
111
125
  // next) re-claims immediately instead of waiting out the lease.
126
+ host.noteSessionLane(opts.sessionKey, null);
112
127
  await ledger.release(lane.laneKey).catch(() => {});
113
128
  throw err;
129
+ } finally {
130
+ if (dispatched) host.noteSessionLane(opts.sessionKey, null);
114
131
  }
115
132
  if (!dispatched) {
116
133
  // Shutdown short-circuit — shutdown() releases all active lanes.
117
134
  return 'shutdown';
118
135
  }
136
+ if (host.consumeTurnError(opts.sessionKey)) {
137
+ // An error turn must not no_action-sweep its members — settle the lane
138
+ // NOW with an error complete so they release for retry on the redrive
139
+ // budget (dispatch-convergence-design.md §3). Settling immediately (even
140
+ // with same-lane work still buffered) is deliberate: carrying the error
141
+ // across buffered turns would let a later reply broad-cover the failed
142
+ // member, or requeue the later turn's successful work. Released members
143
+ // rejoin the next claim, merged with whatever was buffered. Dropping the
144
+ // local dedupe claims lets the server's re-drive hint retrigger the
145
+ // messages immediately instead of waiting out the renotify pacing.
146
+ ledger.markTurnError(lane.laneKey);
147
+ // Clear dedupe for EVERY lane member, not just this batch: an earlier
148
+ // successful batch may have deferred its complete via hasMoreLocal, so
149
+ // the error complete below releases those members too — their redrive
150
+ // would be permanently blocked by a stale local claim.
151
+ for (const msgId of lane.folded.keys()) {
152
+ host.dispatchedMessages.delete(msgId);
153
+ }
154
+ // Invalidate any armed steer state before settling: a pending injection
155
+ // left over from the failed turn would make the retry claim enter the
156
+ // "already injected" path, produce an empty turn, and no_action-sweep the
157
+ // released members without ever retrying them. abortDispatch is the
158
+ // adapter contract's idempotent "clear pending steer state" hook.
159
+ try {
160
+ host.opts.dispatchAdapter.abortDispatch?.(opts.sessionKey);
161
+ } catch {
162
+ // best-effort — a throwing abort must not block the error settlement
163
+ }
164
+ await ledger.completeIfIdle(lane.laneKey, false);
165
+ // 'failed' — callers must NOT record these events as handled: the server
166
+ // just released them for redelivery, and an "already handled" fork prefix
167
+ // (or a consumed fork summary) on the redrive would be a lie.
168
+ return 'failed';
169
+ }
119
170
  const pendingInjections =
120
171
  host.opts.dispatchAdapter.hasPendingInjections?.(opts.sessionKey) ?? false;
121
172
  await ledger.completeIfIdle(lane.laneKey, pendingInjections || opts.hasMoreLocal());
122
173
  return 'dispatched';
123
174
  }
124
175
 
176
+ /** Failure backoff pacing for typed dispatch retries (base 2s, cap 5min). */
177
+ const TYPED_BACKOFF_BASE_MS = 2_000;
178
+ const TYPED_BACKOFF_CAP_MS = 5 * 60_000;
179
+ const TYPED_BACKOFF_MAP_CAP = 512;
180
+
125
181
  /**
126
182
  * Consume one typed dispatch (task/comment/schedule/trigger/approval) under
127
183
  * its typed-lane occupancy guard: claim the dsp:<id> lane (skip when another
128
184
  * pod holds it or the WorkItem is already resolved), run the handler, ack on
129
185
  * success (the doc's option (b): notification delivered, tracked elsewhere),
130
186
  * then release the lane. Legacy run+ack flow when the ledger is unavailable.
187
+ *
188
+ * Repeated failures back off: a consume that ends un-acked re-arms the
189
+ * WorkItem's backoff entry, and the next attempt sleeps out the remaining
190
+ * window before claiming. Without this, complete's release re-drives the
191
+ * item instantly and a persistently-failing consume (e.g. buffered behind a
192
+ * saturated fork pool) spins at wire speed — the client half of the
193
+ * 2026-07-11 poison loop (the server half is the redrive budget).
131
194
  */
132
195
  export async function consumeTypedDispatch(
133
196
  host: LaneFlowHost,
134
197
  ref: { dispatchEventId?: string; sourceType?: string; sourceId?: string },
135
198
  run: (dispatchEventId?: string) => Promise<boolean>,
136
- ack: (dispatchEventId?: string) => void | Promise<void>,
199
+ ack: (dispatchEventId?: string) => boolean | void | Promise<boolean | void>,
137
200
  ): Promise<void> {
201
+ const backoffKey = ref.dispatchEventId ?? `${ref.sourceType}:${ref.sourceId}`;
202
+ const armed = host.typedRedriveBackoff.get(backoffKey);
203
+ if (armed) {
204
+ const waitMs = armed.until - Date.now();
205
+ if (waitMs > 0) {
206
+ host.opts.log?.info(
207
+ `typed dispatch ${backoffKey} backing off ${Math.ceil(waitMs / 1000)}s after ${armed.failures} failed consume(s)`,
208
+ );
209
+ // unref: the wait must never be what keeps the process alive — on
210
+ // SIGTERM the gateway drains and exits while this timer is pending
211
+ // (the re-drive/renotify path re-delivers on the next pod).
212
+ await new Promise<void>((resolve) => {
213
+ const timer = setTimeout(resolve, waitMs);
214
+ timer.unref?.();
215
+ });
216
+ }
217
+ if (host.shuttingDown) return;
218
+ }
219
+ // An ack callback that returns void (legacy custom acks) counts as success;
220
+ // an explicit false (ackDispatchEvent's failed HTTP ack) is a failed
221
+ // consume — clearing backoff there would let an ack outage re-create the
222
+ // wire-speed release/re-drive loop against a pre-budget server.
223
+ const settleAck = (ackResult: boolean | void) => ackResult !== false;
224
+ const settle = (acked: boolean) => {
225
+ if (acked) {
226
+ host.typedRedriveBackoff.delete(backoffKey);
227
+ return;
228
+ }
229
+ const failures = (host.typedRedriveBackoff.get(backoffKey)?.failures ?? 0) + 1;
230
+ const backoffMs = Math.min(TYPED_BACKOFF_CAP_MS, TYPED_BACKOFF_BASE_MS * 2 ** (failures - 1));
231
+ // True LRU: delete-then-set moves an updated key to the tail of the
232
+ // Map's insertion order, so capacity eviction always removes the
233
+ // least-recently-FAILING key — an actively-failing old key must not be
234
+ // evicted ahead of a quieter newer one. Evict only when inserting a new
235
+ // key at capacity (an in-place update never shrinks the map).
236
+ if (
237
+ host.typedRedriveBackoff.delete(backoffKey) === false &&
238
+ host.typedRedriveBackoff.size >= TYPED_BACKOFF_MAP_CAP
239
+ ) {
240
+ const oldest = host.typedRedriveBackoff.keys().next().value;
241
+ if (oldest !== undefined) host.typedRedriveBackoff.delete(oldest);
242
+ }
243
+ host.typedRedriveBackoff.set(backoffKey, { failures, until: Date.now() + backoffMs });
244
+ };
245
+
246
+ // Legacy run+ack (no ledger): same settle-in-finally contract as the lane
247
+ // path — a thrown run/ack must arm backoff, not skip it.
248
+ const runLegacy = async () => {
249
+ let acked = false;
250
+ try {
251
+ if (await run(ref.dispatchEventId)) {
252
+ acked = settleAck(await ack(ref.dispatchEventId));
253
+ }
254
+ } finally {
255
+ settle(acked);
256
+ }
257
+ };
258
+
138
259
  if (!host.laneLedger || host.ledgerDisabled) {
139
- if (await run(ref.dispatchEventId)) await ack(ref.dispatchEventId);
260
+ await runLegacy();
140
261
  return;
141
262
  }
142
263
  let lane: Awaited<ReturnType<LaneLedger['claimTyped']>>;
@@ -145,23 +266,29 @@ export async function consumeTypedDispatch(
145
266
  } catch (err) {
146
267
  if (err instanceof LedgerUnsupportedError) {
147
268
  host.disableLedger('claim endpoint missing');
148
- if (await run(ref.dispatchEventId)) await ack(ref.dispatchEventId);
269
+ await runLegacy();
149
270
  return;
150
271
  }
151
272
  throw err;
152
273
  }
153
274
  if (!lane) {
275
+ // Held elsewhere or already resolved — not a local failure; leave the
276
+ // backoff state as-is (a stale entry is cleared by the next local ack).
154
277
  host.opts.log?.info(
155
278
  `typed dispatch ${ref.dispatchEventId ?? `${ref.sourceType}:${ref.sourceId}`} not claimable (held elsewhere or already resolved) — skipping`,
156
279
  );
157
280
  return;
158
281
  }
282
+ let acked = false;
159
283
  try {
160
284
  // Ack must settle before Complete. A fire-and-forget ack races the lane
161
285
  // release: Complete can return the still-received typed item to pending
162
286
  // and publish a re-drive while its successful ack is still in flight.
163
- if (await run(lane.typedDispatchEventId)) await ack(lane.typedDispatchEventId);
287
+ if (await run(lane.typedDispatchEventId)) {
288
+ acked = settleAck(await ack(lane.typedDispatchEventId));
289
+ }
164
290
  } finally {
291
+ settle(acked);
165
292
  // Release the occupancy row. A buffered dispatch may outlive this guard
166
293
  // (lane TTL) — acceptable at-least-once; the ledger's resolution paths
167
294
  // still dedupe the persistent side effects.
package/src/index.ts CHANGED
@@ -2,6 +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
6
  export * from './session-state.js';
6
7
  export * from './routing.js';
7
8
  export * from './event-format.js';
@@ -11,6 +12,8 @@ export * from './dispatch-adapter.js';
11
12
  export { createLogger, childLogger } from './logger.js';
12
13
  export * from './gateway-base.js';
13
14
  export * from './platform-config.js';
15
+ export * from './channel-capability.js';
16
+ export * from './channel-token.js';
14
17
  export { writeSkillFiles, buildSkillReferences, SKILLS } from './skills/index.js';
15
18
  export type { SkillMeta } from './skills/index.js';
16
19
  export {
@@ -19,6 +19,13 @@ export type ActiveLane = {
19
19
  /** Lease expiry (ms epoch) and TTL from the claim — renewal pacing state. */
20
20
  leaseUntilMs?: number;
21
21
  leaseTtlMs?: number;
22
+ /**
23
+ * Sticky error bit: some turn on this lane surfaced a runtime error. Read
24
+ * (and only cleared) by the lane's final complete, so an error outcome
25
+ * survives buffered same-lane turns in between — a later successful turn
26
+ * must not no_action-sweep the failed turn's members.
27
+ */
28
+ turnError?: boolean;
22
29
  };
23
30
 
24
31
  /**
@@ -122,9 +129,19 @@ export class LaneLedger {
122
129
  throw err;
123
130
  }
124
131
  if (!res.claimed || !res.lane) {
125
- this.opts.log?.info(
126
- `lane for ${targetUri} held by a healthy incumbent — leaving events pending for re-drive`,
127
- );
132
+ if (res.reason === 'empty') {
133
+ // Nothing foldable: resolved elsewhere, or stranded outside the
134
+ // ledger (the reconciler reclaims strays past the received TTL).
135
+ // Post-convergence this should not happen on a message lane — warn
136
+ // so a regression surfaces (design §3).
137
+ this.opts.log?.warn(
138
+ `claim for ${targetUri} came back empty — nothing foldable; leaving to the reconciler`,
139
+ );
140
+ } else {
141
+ this.opts.log?.info(
142
+ `lane for ${targetUri} held by a healthy incumbent — leaving events pending for re-drive`,
143
+ );
144
+ }
128
145
  return null;
129
146
  }
130
147
  const leaseUntilMs = Date.parse(res.lease_until ?? '');
@@ -218,6 +235,18 @@ export class LaneLedger {
218
235
  * re-drives any same-target pending work. A STALE_LANE answer means a
219
236
  * takeover already owns the resource — local state is dropped either way.
220
237
  */
238
+ /**
239
+ * Record that the turn on this lane surfaced a runtime error. The flow
240
+ * settles an errored lane immediately (dispatchLaneGroup returns 'failed'
241
+ * after a forced complete), so the bit normally lives for one turn only —
242
+ * it is the transport between the gateway's per-session error signal and
243
+ * this lane's complete request.
244
+ */
245
+ markTurnError(laneKey: string): void {
246
+ const lane = this.lanes.get(laneKey);
247
+ if (lane) lane.turnError = true;
248
+ }
249
+
221
250
  async completeIfIdle(laneKey: string, hasMoreLocal: boolean): Promise<void> {
222
251
  const lane = this.lanes.get(laneKey);
223
252
  if (!lane || hasMoreLocal) return;
@@ -228,6 +257,9 @@ export class LaneLedger {
228
257
  lane: lane.lane,
229
258
  target_uri: lane.targetUri,
230
259
  thread_root_id: lane.threadRootId,
260
+ // An error turn releases its members for retry instead of sweeping
261
+ // them as handled (ignored by older servers).
262
+ turn_outcome: lane.turnError ? 'error' : 'ok',
231
263
  });
232
264
  if (res.swept_no_action > 0 || res.redriven) {
233
265
  this.opts.log?.info(
@@ -244,6 +276,17 @@ export class LaneLedger {
244
276
  }
245
277
  }
246
278
 
279
+ /**
280
+ * Renew one lane by its key — the external runtime-activity hook for
281
+ * adapters whose tool traffic bypasses the RuntimeEvent stream (openclaw
282
+ * hooks). Scoped to the session's own lane: renewing every lane would let
283
+ * one busy fork keep an unrelated stalled fork's lane leased forever.
284
+ */
285
+ renewByKey(laneKey: string): void {
286
+ const lane = this.lanes.get(laneKey);
287
+ if (lane) this.maybeRenew(lane);
288
+ }
289
+
247
290
  /**
248
291
  * Long-turn keepalive: renew the lane's lease on runtime activity, throttled
249
292
  * so a chatty turn doesn't spam the server. Without this, a legitimately
@@ -17,6 +17,46 @@ export interface PlatformConfigManager {
17
17
  fetch(): Promise<PlatformDefaults>;
18
18
  current(): PlatformDefaults;
19
19
  rawConfig(): Record<string, unknown> | null;
20
+ /**
21
+ * Channel capabilities delivered in `agents.capabilities[]` — the
22
+ * declaration plane of the channel-capability broker. Reads the current
23
+ * (possibly LKG-cached) config with NO freshness gate: the credential is
24
+ * pull-at-use (the mint endpoint re-evaluates the grant on every call), so
25
+ * a stale declaration fail-closes there with a self-explanatory 403 — like
26
+ * a stale model, it is suboptimal, never dangerous.
27
+ */
28
+ capabilities(): AgentCapability[];
29
+ }
30
+
31
+ // One platform-granted capability (agents.capabilities[] in platform-config).
32
+ // `fragment` is the complete system-prompt declaration text; the server is
33
+ // the SSOT for channel knowledge, runtimes just splice it in.
34
+ export interface AgentCapability {
35
+ key: string;
36
+ source: string;
37
+ fragment: string;
38
+ }
39
+
40
+ // Defensive extraction: entries missing a non-empty string key/fragment are
41
+ // skipped so a malformed or future-shaped payload can never inject a blank
42
+ // declaration into a system prompt.
43
+ export function extractCapabilities(config: Record<string, unknown>): AgentCapability[] {
44
+ const agents = (config.agents ?? {}) as Record<string, unknown>;
45
+ const raw = agents.capabilities;
46
+ if (!Array.isArray(raw)) return [];
47
+ const out: AgentCapability[] = [];
48
+ for (const entry of raw) {
49
+ if (typeof entry !== 'object' || entry === null) continue;
50
+ const e = entry as Record<string, unknown>;
51
+ if (typeof e.key !== 'string' || !e.key) continue;
52
+ if (typeof e.fragment !== 'string' || !e.fragment) continue;
53
+ out.push({
54
+ key: e.key,
55
+ source: typeof e.source === 'string' ? e.source : '',
56
+ fragment: e.fragment,
57
+ });
58
+ }
59
+ return out;
20
60
  }
21
61
 
22
62
  export interface PlatformManagementProfile {
@@ -276,5 +316,9 @@ export function createPlatformConfigManager(opts: {
276
316
  rawConfig(): Record<string, unknown> | null {
277
317
  return currentRawConfig;
278
318
  },
319
+
320
+ capabilities(): AgentCapability[] {
321
+ return currentRawConfig ? extractCapabilities(currentRawConfig) : [];
322
+ },
279
323
  };
280
324
  }