@parall/agent-core 1.43.0 → 1.45.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.
@@ -41,8 +41,11 @@ import {
41
41
  consumeMessageWorkItem,
42
42
  consumeTypedDispatch,
43
43
  dispatchLaneGroup,
44
+ resolveDispatchByID,
45
+ settleDrainedTypedGroup,
46
+ typedLedgerEventIds,
44
47
  } from './gateway-lane-flow.js';
45
- import type { LaneFlowHost } from './gateway-lane-flow.js';
48
+ import type { LaneFlowHost, TypedConsumeHooks } from './gateway-lane-flow.js';
46
49
  import { LaneLedger } from './lane-ledger.js';
47
50
  import { routeTrigger } from './routing.js';
48
51
  import {
@@ -447,30 +450,33 @@ export class ParallAgentGateway {
447
450
  ? { dispatchEventId: data.dispatch_event_id }
448
451
  : { sourceType: 'task_activity', sourceId: data.id },
449
452
  (dispatchEventId) => this.handleTaskAssignment(data, data.id, dispatchEventId),
450
- (dispatchEventId) => {
451
- if (dispatchEventId) {
452
- return this.ackDispatchEvent(dispatchEventId, () => {
453
- this.dispatchedTasks.delete(`${data.id}:${data.updated_at}`);
454
- });
455
- }
456
- return this.opts.client
457
- .ackDispatch(this.opts.config.org_id, {
458
- source_type: 'task_activity',
459
- source_id: data.id,
460
- })
461
- .then(
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.
453
+ {
454
+ legacyAck: (dispatchEventId) => {
455
+ if (dispatchEventId) {
456
+ return this.ackDispatchEvent(dispatchEventId, () => {
467
457
  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
- },
473
- );
458
+ });
459
+ }
460
+ return this.opts.client
461
+ .ackDispatch(this.opts.config.org_id, {
462
+ source_type: 'task_activity',
463
+ source_id: data.id,
464
+ })
465
+ .then(
466
+ () => true,
467
+ (err) => {
468
+ // Same contract as ackDispatchEvent: a failed ack is a
469
+ // failed consume (arms backoff) and must free the hot-path
470
+ // dedupe so the re-drive isn't rejected by this pod forever.
471
+ this.dispatchedTasks.delete(`${data.id}:${data.updated_at}`);
472
+ this.opts.log?.warn(
473
+ `dispatch ack failed for task ${data.id}, releasing for re-drive: ${String(err)}`,
474
+ );
475
+ return false;
476
+ },
477
+ );
478
+ },
479
+ clearDedupe: () => this.dispatchedTasks.delete(`${data.id}:${data.updated_at}`),
474
480
  },
475
481
  );
476
482
  } catch (err) {
@@ -493,14 +499,19 @@ export class ParallAgentGateway {
493
499
  try {
494
500
  await this.consumeTypedDispatch(
495
501
  { dispatchEventId: data.id },
496
- () =>
502
+ (dispatchEventId) =>
497
503
  this.handleTaskComment(
498
504
  data.source_id,
499
505
  data.task_id ?? '',
500
506
  data.actor_id,
501
507
  data.delivery_reason,
508
+ dispatchEventId,
502
509
  ),
503
- () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
510
+ {
511
+ legacyAck: () =>
512
+ this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
513
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
514
+ },
504
515
  );
505
516
  } catch (err) {
506
517
  this.opts.log?.error(
@@ -512,8 +523,18 @@ export class ParallAgentGateway {
512
523
  try {
513
524
  await this.consumeTypedDispatch(
514
525
  { dispatchEventId: data.id },
515
- () => this.handleWikiComment(data.source_id, data.actor_id, data.delivery_reason),
516
- () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
526
+ (dispatchEventId) =>
527
+ this.handleWikiComment(
528
+ data.source_id,
529
+ data.actor_id,
530
+ data.delivery_reason,
531
+ dispatchEventId,
532
+ ),
533
+ {
534
+ legacyAck: () =>
535
+ this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
536
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
537
+ },
517
538
  );
518
539
  } catch (err) {
519
540
  this.opts.log?.error(
@@ -530,7 +551,11 @@ export class ParallAgentGateway {
530
551
  allowCreator: true,
531
552
  dispatchEventId,
532
553
  }),
533
- () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
554
+ {
555
+ legacyAck: () =>
556
+ this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
557
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
558
+ },
534
559
  );
535
560
  } catch (err) {
536
561
  this.opts.log?.error(`task update dispatch failed for ${data.task_id}: ${String(err)}`);
@@ -540,8 +565,13 @@ export class ParallAgentGateway {
540
565
  try {
541
566
  await this.consumeTypedDispatch(
542
567
  { dispatchEventId: data.id },
543
- () => this.fetchAndHandleScheduleFire(data.source_id, data.actor_id),
544
- () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
568
+ (dispatchEventId) =>
569
+ this.fetchAndHandleScheduleFire(data.source_id, data.actor_id, dispatchEventId),
570
+ {
571
+ legacyAck: () =>
572
+ this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
573
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
574
+ },
545
575
  );
546
576
  } catch (err) {
547
577
  this.opts.log?.error(
@@ -553,8 +583,13 @@ export class ParallAgentGateway {
553
583
  try {
554
584
  await this.consumeTypedDispatch(
555
585
  { dispatchEventId: data.id },
556
- () => this.fetchAndHandleExternalTriggerRun(data.source_id),
557
- () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
586
+ (dispatchEventId) =>
587
+ this.fetchAndHandleExternalTriggerRun(data.source_id, dispatchEventId),
588
+ {
589
+ legacyAck: () =>
590
+ this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
591
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
592
+ },
558
593
  );
559
594
  } catch (err) {
560
595
  this.opts.log?.error(
@@ -566,8 +601,12 @@ export class ParallAgentGateway {
566
601
  try {
567
602
  await this.consumeTypedDispatch(
568
603
  { dispatchEventId: data.id },
569
- () => this.fetchAndHandleChannelMessage(data.source_id),
570
- () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
604
+ (dispatchEventId) => this.fetchAndHandleChannelMessage(data.source_id, dispatchEventId),
605
+ {
606
+ legacyAck: () =>
607
+ this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
608
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
609
+ },
571
610
  );
572
611
  } catch (err) {
573
612
  this.opts.log?.error(
@@ -579,13 +618,18 @@ export class ParallAgentGateway {
579
618
  try {
580
619
  await this.consumeTypedDispatch(
581
620
  { dispatchEventId: data.id },
582
- () =>
621
+ (dispatchEventId) =>
583
622
  this.fetchAndHandleApprovalDecided(
584
623
  data.source_id,
585
624
  data.actor_id,
586
625
  data.chat_id ?? null,
626
+ dispatchEventId,
587
627
  ),
588
- () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
628
+ {
629
+ legacyAck: () =>
630
+ this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
631
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
632
+ },
589
633
  );
590
634
  } catch (err) {
591
635
  this.opts.log?.error(
@@ -641,6 +685,45 @@ export class ParallAgentGateway {
641
685
  return true;
642
686
  }
643
687
 
688
+ /**
689
+ * Lane currently being dispatched per session — lets external activity
690
+ * signals renew exactly the caller's lane (renewing all lanes would keep
691
+ * an unrelated stalled fork's lane leased forever).
692
+ */
693
+ private sessionActiveLanes = new Map<string, string>();
694
+
695
+ noteSessionLane(sessionKey: string, laneKey: string | null): void {
696
+ if (laneKey == null) this.sessionActiveLanes.delete(sessionKey);
697
+ else this.sessionActiveLanes.set(sessionKey, laneKey);
698
+ }
699
+
700
+ /**
701
+ * External runtime-activity signal for adapters whose tool activity does
702
+ * not flow through the RuntimeEvent stream (openclaw hooks call this from
703
+ * the tool-call lifecycle): renews the session's OWN active ledger lane so
704
+ * a long tool call cannot outlive the lease and get dethroned mid-turn.
705
+ * No-op without an active ledger lane for the session.
706
+ */
707
+ touchRuntimeActivity(sessionKey: string): void {
708
+ if (this.ledgerDisabled) return;
709
+ const laneKey = this.sessionActiveLanes.get(sessionKey);
710
+ if (laneKey) this.laneLedger?.renewByKey(laneKey);
711
+ }
712
+
713
+ /** Sessions whose in-flight turn surfaced a runtime error event. */
714
+ private turnErrorSessions = new Set<string>();
715
+
716
+ /**
717
+ * Consume (read-and-clear) the error marker for sessionKey's last turn.
718
+ * Feeds complete's turn_outcome so an error turn's lane members are
719
+ * released for retry instead of no_action-swept (design §3). Consuming
720
+ * (rather than peeking) keeps one-shot fork session keys from accumulating
721
+ * in the set forever.
722
+ */
723
+ consumeTurnError(sessionKey: string): boolean {
724
+ return this.turnErrorSessions.delete(sessionKey);
725
+ }
726
+
644
727
  private async emitDispatchReceived(event: ParallEvent): Promise<void> {
645
728
  const sourceType = event.ackSourceType ?? (event.type === 'task' ? 'task_activity' : 'message');
646
729
  const sourceId = event.ackSourceId ?? event.messageId;
@@ -650,6 +733,26 @@ export class ParallAgentGateway {
650
733
  });
651
734
  }
652
735
 
736
+ /**
737
+ * Sticky: the server answered a by-id complete with 400 (predates the
738
+ * form). Typed resolution falls back to the legacy ack for the rest of
739
+ * the process lifetime.
740
+ */
741
+ typedByIdCompleteUnsupported = false;
742
+
743
+ // Typed-face ledger helpers live in gateway-lane-flow.ts; thin delegates
744
+ // keep the site code and tests on the class surface.
745
+ private typedLedgerEventIds(events: ParallEvent[]): string[] | null {
746
+ return typedLedgerEventIds(this.laneFlowHost(), events);
747
+ }
748
+
749
+ private resolveDispatchByID(
750
+ dispatchEventId: string,
751
+ lane?: string,
752
+ ): ReturnType<typeof resolveDispatchByID> {
753
+ return resolveDispatchByID(this.laneFlowHost(), dispatchEventId, lane);
754
+ }
755
+
653
756
  /** True when this event's lifecycle is owned by the dispatch lane ledger. */
654
757
  private usesLaneLedger(event: ParallEvent): boolean {
655
758
  return this.laneLedger != null && !this.ledgerDisabled && this.laneLedger.handles(event);
@@ -693,24 +796,25 @@ export class ParallAgentGateway {
693
796
  earlier: ParallEvent[];
694
797
  captureText?: string[];
695
798
  hasMoreLocal: () => boolean;
696
- }): Promise<'dispatched' | 'foreign' | 'shutdown'> {
799
+ }): Promise<'dispatched' | 'foreign' | 'shutdown' | 'failed'> {
697
800
  return dispatchLaneGroup(this.laneFlowHost(), opts);
698
801
  }
699
802
 
700
803
  private consumeTypedDispatch(
701
804
  ref: { dispatchEventId?: string; sourceType?: string; sourceId?: string },
702
805
  run: (dispatchEventId?: string) => Promise<boolean>,
703
- ack: (dispatchEventId?: string) => boolean | void | Promise<boolean | void>,
806
+ hooks: TypedConsumeHooks,
704
807
  ): Promise<void> {
705
- return consumeTypedDispatch(this.laneFlowHost(), ref, run, ack);
808
+ return consumeTypedDispatch(this.laneFlowHost(), ref, run, hooks);
706
809
  }
707
810
 
708
- // Typed completion must wait until the administrative ack has either
709
- // committed or failed. Errors stay best-effort: a failed ack leaves the row
710
- // received, so Complete releases and re-drives it safely. The boolean
711
- // outcome feeds the typed-consume backoff an ack that failed must count
712
- // as a failed consume, or an ack outage would clear the backoff entry and
713
- // let the release re-drive spin at wire speed.
811
+ // Legacy administrative ack (ledger-disabled fallback only). Typed
812
+ // completion must wait until the ack has either committed or failed.
813
+ // Errors stay best-effort: a failed ack leaves the row received, so
814
+ // Complete releases and re-drives it safely. The boolean outcome feeds the
815
+ // typed-consume backoff an ack that failed must count as a failed
816
+ // consume, or an ack outage would clear the backoff entry and let the
817
+ // release re-drive spin at wire speed.
714
818
  private ackDispatchEvent(dispatchEventId: string, onFailure?: () => void): Promise<boolean> {
715
819
  return this.opts.client.ackDispatchByID(this.opts.config.org_id, dispatchEventId).then(
716
820
  () => true,
@@ -727,6 +831,9 @@ export class ParallAgentGateway {
727
831
  );
728
832
  }
729
833
 
834
+ // PARITY: this switch and gateway-lane-flow's clearTypedDedupeForEvent must
835
+ // handle the same typed source families — extend BOTH when adding a typed
836
+ // event type (same dedupe entries, keyed from different event shapes).
730
837
  private clearTypedDispatchDedupe(item: DispatchNewData): void {
731
838
  switch (item.event_type) {
732
839
  case 'task_assign':
@@ -1137,6 +1244,7 @@ export class ParallAgentGateway {
1137
1244
  }
1138
1245
 
1139
1246
  resetDispatchMetrics(sessionKey);
1247
+ this.turnErrorSessions.delete(sessionKey);
1140
1248
  return runWithSessionKey(sessionKey, async () => {
1141
1249
  let dispatchSpan: ReturnType<typeof startDispatchSpan> = null;
1142
1250
 
@@ -1299,6 +1407,9 @@ export class ParallAgentGateway {
1299
1407
  ) {
1300
1408
  recordMessageSend(sessionKey, !runtimeEvent.error);
1301
1409
  }
1410
+ if (runtimeEvent.type === 'error') {
1411
+ this.turnErrorSessions.add(sessionKey);
1412
+ }
1302
1413
  await this.createRuntimeStep(
1303
1414
  binding.agentSessionId,
1304
1415
  event,
@@ -1495,13 +1606,29 @@ export class ParallAgentGateway {
1495
1606
  body: buildForkScopePrefix(last) + buildEventBody(last),
1496
1607
  earlier,
1497
1608
  captureText: batchText,
1498
- hasMoreLocal: () => fork.queue.length > 0,
1609
+ // Per-LANE residue check (parity with the main-buffer path):
1610
+ // the fork queue can hold several lanes (channel + thread of
1611
+ // the same chat). A whole-queue check would defer THIS lane's
1612
+ // complete behind another lane's items and never revisit it —
1613
+ // its members would sit received until lease expiry.
1614
+ hasMoreLocal: () =>
1615
+ fork.queue.some(
1616
+ (it) => this.dispatchGroupKey(it.event) === this.dispatchGroupKey(last),
1617
+ ),
1499
1618
  });
1500
1619
  if (outcome === 'foreign') {
1501
1620
  // Another pod owns the lane — the events stay pending server-side.
1502
1621
  for (const item of items) item.resolve(false);
1503
1622
  break;
1504
1623
  }
1624
+ if (outcome === 'failed') {
1625
+ // Error turn: complete(error) already released the members for
1626
+ // redelivery. Do NOT push them to processedEvents — the redrive
1627
+ // must not arrive wearing an "already handled" prefix. Keep
1628
+ // draining; the released work rejoins the next claim.
1629
+ for (const item of items) item.resolve(false);
1630
+ continue;
1631
+ }
1505
1632
  dispatched = outcome === 'dispatched';
1506
1633
  } else {
1507
1634
  dispatched = await this.runDispatch(
@@ -1511,6 +1638,22 @@ export class ParallAgentGateway {
1511
1638
  earlier,
1512
1639
  batchText,
1513
1640
  );
1641
+ if (
1642
+ dispatched &&
1643
+ this.typedLedgerEventIds(events) &&
1644
+ this.consumeTurnError(fork.fork.sessionKey)
1645
+ ) {
1646
+ // Typed error turn in a fork: report failure to the awaiting
1647
+ // typed consumes so the members release for a budgeted retry,
1648
+ // and do NOT record the events as handled — the redrive must
1649
+ // not arrive wearing an "already handled" prefix. Keep draining
1650
+ // (an error turn is not a shutdown).
1651
+ this.opts.log?.info(
1652
+ `typed fork turn for ${last.messageId} surfaced a runtime error — releasing for retry`,
1653
+ );
1654
+ for (const item of items) item.resolve(false);
1655
+ continue;
1656
+ }
1514
1657
  }
1515
1658
  if (!dispatched) {
1516
1659
  // Shutdown short-circuit — resolve un-acked so the server requeues
@@ -1542,6 +1685,10 @@ export class ParallAgentGateway {
1542
1685
  remaining.resolve(false);
1543
1686
  }
1544
1687
  } finally {
1688
+ // One-shot fork keys are never dispatched again — drop any error marker
1689
+ // the legacy (non-ledger) path left unconsumed, or the set grows with
1690
+ // every failed ephemeral fork.
1691
+ this.turnErrorSessions.delete(fork.fork.sessionKey);
1545
1692
  if (fork.deadlineTimer) {
1546
1693
  clearTimeout(fork.deadlineTimer);
1547
1694
  fork.deadlineTimer = null;
@@ -1654,7 +1801,7 @@ export class ParallAgentGateway {
1654
1801
  this.opts.runtimeKey,
1655
1802
  );
1656
1803
  if (this.usesLaneLedger(event)) {
1657
- let outcome: 'dispatched' | 'foreign' | 'shutdown';
1804
+ let outcome: 'dispatched' | 'foreign' | 'shutdown' | 'failed';
1658
1805
  try {
1659
1806
  outcome = await this.dispatchLaneGroup({
1660
1807
  events,
@@ -1681,19 +1828,33 @@ export class ParallAgentGateway {
1681
1828
  this.dispatchState.pendingForkResults.unshift(...pendingFork);
1682
1829
  continue;
1683
1830
  }
1831
+ if (outcome === 'failed') {
1832
+ // Error turn settled with complete(error) — members released for
1833
+ // redelivery. The fork results were not consumed by the failed
1834
+ // turn; keep them for the next real dispatch.
1835
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1836
+ continue;
1837
+ }
1684
1838
  // Dispatched — resolution happened server-side (reply cover or
1685
1839
  // no_action sweep); no legacy acks.
1686
1840
  continue;
1687
1841
  }
1688
- try {
1689
- await this.emitDispatchReceived(event);
1690
- } catch (err) {
1691
- this.opts.log?.warn?.(
1692
- `mark-received failed for buffered dispatch, leaving unacked for retry: ${String(err)}`,
1693
- );
1694
- this.dispatchState.mainBuffer.unshift(...events);
1695
- this.dispatchState.pendingForkResults.unshift(...pendingFork);
1696
- break;
1842
+ // Buffered typed events are the one wrapper-less dispatch path: their
1843
+ // consumeTypedDispatch guard returned long ago (buffer-main resolves
1844
+ // false) and released the claims, so THIS site owns their resolution.
1845
+ // Message groups here are the ledger-disabled legacy flow.
1846
+ const typedRefs = this.typedLedgerEventIds(events);
1847
+ if (!typedRefs) {
1848
+ try {
1849
+ await this.emitDispatchReceived(event);
1850
+ } catch (err) {
1851
+ this.opts.log?.warn?.(
1852
+ `mark-received failed for buffered dispatch, leaving unacked for retry: ${String(err)}`,
1853
+ );
1854
+ this.dispatchState.mainBuffer.unshift(...events);
1855
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1856
+ break;
1857
+ }
1697
1858
  }
1698
1859
  const dispatched = await this.runDispatch(
1699
1860
  event,
@@ -1709,6 +1870,18 @@ export class ParallAgentGateway {
1709
1870
  this.dispatchState.pendingForkResults.unshift(...pendingFork);
1710
1871
  break;
1711
1872
  }
1873
+ if (typedRefs) {
1874
+ // Wrapper-less resolution of the buffered typed group — protocol
1875
+ // lives in gateway-lane-flow.ts. The turn-error marker is consumed
1876
+ // HERE, before this loop can start another turn on the session.
1877
+ await settleDrainedTypedGroup(
1878
+ this.laneFlowHost(),
1879
+ events,
1880
+ typedRefs,
1881
+ this.consumeTurnError(this.opts.runtimeKey),
1882
+ );
1883
+ continue;
1884
+ }
1712
1885
  for (const bufferedEvent of events) {
1713
1886
  const sourceType =
1714
1887
  bufferedEvent.ackSourceType ??
@@ -1769,7 +1942,7 @@ export class ParallAgentGateway {
1769
1942
  if (this.usesLaneLedger(event)) {
1770
1943
  // Ledger flow: claim replaces mark-received; complete/reply replace
1771
1944
  // acks. A foreign incumbent leaves the event pending for re-drive.
1772
- let outcome: 'dispatched' | 'foreign' | 'shutdown' = 'shutdown';
1945
+ let outcome: 'dispatched' | 'foreign' | 'shutdown' | 'failed' = 'shutdown';
1773
1946
  try {
1774
1947
  try {
1775
1948
  outcome = await this.dispatchLaneGroup({
@@ -1796,16 +1969,24 @@ export class ParallAgentGateway {
1796
1969
  }
1797
1970
  return outcome === 'dispatched';
1798
1971
  }
1799
- try {
1800
- await this.emitDispatchReceived(event);
1801
- } catch (err) {
1802
- this.opts.log?.warn?.(`mark-received failed, leaving unacked for retry: ${String(err)}`);
1803
- this.dispatchState.mainDispatching = false;
1804
- this.dispatchState.mainCurrentTargetId = undefined;
1805
- this.mainCurrentGroupKey = undefined;
1806
- this.dispatchState.mainPreDispatchBranchPoint = undefined;
1807
- this.dispatchState.pendingForkResults.unshift(...pendingFork);
1808
- return false;
1972
+ // Ledger-claimed typed events skip the legacy received write — the
1973
+ // claim already marked the row received, and a second mark here would
1974
+ // wipe its lease owner (the 0713 black-hole shape on the typed face).
1975
+ const typedRefs = this.typedLedgerEventIds([event]);
1976
+ if (!typedRefs) {
1977
+ try {
1978
+ await this.emitDispatchReceived(event);
1979
+ } catch (err) {
1980
+ this.opts.log?.warn?.(
1981
+ `mark-received failed, leaving unacked for retry: ${String(err)}`,
1982
+ );
1983
+ this.dispatchState.mainDispatching = false;
1984
+ this.dispatchState.mainCurrentTargetId = undefined;
1985
+ this.mainCurrentGroupKey = undefined;
1986
+ this.dispatchState.mainPreDispatchBranchPoint = undefined;
1987
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1988
+ return false;
1989
+ }
1809
1990
  }
1810
1991
  let dispatched = false;
1811
1992
  try {
@@ -1814,6 +1995,17 @@ export class ParallAgentGateway {
1814
1995
  this.opts.runtimeKey,
1815
1996
  forkPrefix + buildEventBody(event),
1816
1997
  );
1998
+ if (dispatched && typedRefs && this.consumeTurnError(this.opts.runtimeKey)) {
1999
+ // Typed error turn: report failure to the enclosing typed consume
2000
+ // so the member releases for a budgeted retry instead of being
2001
+ // terminally resolved (tech-debt: typed-dispatch-error-outcome).
2002
+ // Consumed HERE, before the finally's drain can start another
2003
+ // turn on this session and clear the marker.
2004
+ this.opts.log?.info(
2005
+ `typed dispatch turn for ${event.messageId} surfaced a runtime error — releasing for retry`,
2006
+ );
2007
+ dispatched = false;
2008
+ }
1817
2009
  if (!dispatched) {
1818
2010
  // Shutdown short-circuit — restore the fork results so a future
1819
2011
  // pod can replay them, and return false so handleMessage skips ack.
@@ -1840,10 +2032,17 @@ export class ParallAgentGateway {
1840
2032
  // fold leaves the event buffered — the drain claims it as its own
1841
2033
  // turn. Injection requires an exact lane match (same chat AND same
1842
2034
  // thread) — a thread message never rides a channel turn.
2035
+ // The adapter-capability check comes BEFORE the fold: on a
2036
+ // buffer-only adapter (openclaw — no enqueueDuringDispatch) a
2037
+ // folded-but-never-injected member would be broad-covered by the
2038
+ // current turn's reply without the model ever seeing it. Leaving
2039
+ // the event un-folded keeps it buffered; the drain claims it as
2040
+ // its own turn and folds it there.
1843
2041
  if (
1844
2042
  this.mainCurrentGroupKey === this.dispatchGroupKey(event) &&
2043
+ this.opts.dispatchAdapter.enqueueDuringDispatch != null &&
1845
2044
  (await this.laneLedger?.steerLive(event)) &&
1846
- (await this.opts.dispatchAdapter.enqueueDuringDispatch?.(
2045
+ (await this.opts.dispatchAdapter.enqueueDuringDispatch(
1847
2046
  this.opts.runtimeKey,
1848
2047
  buildEventBody(event),
1849
2048
  ))
@@ -1892,14 +2091,22 @@ export class ParallAgentGateway {
1892
2091
  return false;
1893
2092
  }
1894
2093
 
1895
- try {
1896
- await this.emitDispatchReceived(event);
1897
- } catch (err) {
1898
- this.opts.log?.warn?.(
1899
- `mark-received failed for fork dispatch, leaving unacked for retry: ${String(err)}`,
1900
- );
1901
- this.dispatchState.mainBuffer.push(event);
1902
- return false;
2094
+ // Ledger-managed events must never touch the legacy received surface
2095
+ // — the fork's lane claim marks them received. A mark-received here
2096
+ // outruns the claim, strands the row ownerless, and the chat goes
2097
+ // silent (the 0713 black hole; dispatch-convergence-design.md §6).
2098
+ // Same rule for ledger-claimed typed events: their dsp-lane claim
2099
+ // owns received-ness, and a re-mark would wipe the lease owner.
2100
+ if (!this.usesLaneLedger(event) && !this.typedLedgerEventIds([event])) {
2101
+ try {
2102
+ await this.emitDispatchReceived(event);
2103
+ } catch (err) {
2104
+ this.opts.log?.warn?.(
2105
+ `mark-received failed for fork dispatch, leaving unacked for retry: ${String(err)}`,
2106
+ );
2107
+ this.dispatchState.mainBuffer.push(event);
2108
+ return false;
2109
+ }
1903
2110
  }
1904
2111
  const fork = await this.opts.dispatchAdapter.forkSession({
1905
2112
  sessionKey: this.opts.runtimeKey,
@@ -2101,10 +2308,13 @@ export class ParallAgentGateway {
2101
2308
  this.handleTaskDispatch(item.task_id ?? '', item.source_id ?? item.task_id ?? '', {
2102
2309
  dispatchEventId,
2103
2310
  }),
2104
- (dispatchEventId) =>
2105
- this.ackDispatchEvent(dispatchEventId ?? item.id, () =>
2106
- this.clearTypedDispatchDedupe(item),
2107
- ),
2311
+ {
2312
+ legacyAck: (dispatchEventId) =>
2313
+ this.ackDispatchEvent(dispatchEventId ?? item.id, () =>
2314
+ this.clearTypedDispatchDedupe(item),
2315
+ ),
2316
+ clearDedupe: () => this.clearTypedDispatchDedupe(item),
2317
+ },
2108
2318
  );
2109
2319
  }
2110
2320
 
@@ -2187,6 +2397,7 @@ export class ParallAgentGateway {
2187
2397
  taskId: string,
2188
2398
  actorId: string | null,
2189
2399
  deliveryReason?: DispatchDeliveryReason | null,
2400
+ dispatchEventId?: string,
2190
2401
  ): Promise<boolean> {
2191
2402
  if (this.shuttingDown) return false; // drain window — let server requeue via catch-up
2192
2403
  const dedupeKey = `comment:${commentId}`;
@@ -2251,6 +2462,7 @@ export class ParallAgentGateway {
2251
2462
  deliveryReason: deliveryReason ?? undefined,
2252
2463
  ackSourceType: 'comment',
2253
2464
  ackSourceId: commentId,
2465
+ dispatchEventId,
2254
2466
  };
2255
2467
 
2256
2468
  let dispatched: boolean;
@@ -2271,6 +2483,7 @@ export class ParallAgentGateway {
2271
2483
  commentId: string,
2272
2484
  actorId: string | null,
2273
2485
  deliveryReason?: DispatchDeliveryReason | null,
2486
+ dispatchEventId?: string,
2274
2487
  ): Promise<boolean> {
2275
2488
  if (this.shuttingDown) return false; // drain window — let server requeue via catch-up
2276
2489
  // Shares the comment dedupe namespace with handleTaskComment; comment IDs
@@ -2326,6 +2539,7 @@ export class ParallAgentGateway {
2326
2539
  replyTargetUri: comment.target_uri,
2327
2540
  ackSourceType: 'comment',
2328
2541
  ackSourceId: commentId,
2542
+ dispatchEventId,
2329
2543
  };
2330
2544
 
2331
2545
  let dispatched: boolean;
@@ -2353,6 +2567,7 @@ export class ParallAgentGateway {
2353
2567
  private async fetchAndHandleScheduleFire(
2354
2568
  runId: string,
2355
2569
  actorId: string | null,
2570
+ dispatchEventId?: string,
2356
2571
  ): Promise<boolean> {
2357
2572
  let run: ScheduleRun | null = null;
2358
2573
  try {
@@ -2374,10 +2589,14 @@ export class ParallAgentGateway {
2374
2589
  return false;
2375
2590
  }
2376
2591
  if (!run) return true;
2377
- return this.handleScheduleFire(run, actorId);
2592
+ return this.handleScheduleFire(run, actorId, dispatchEventId);
2378
2593
  }
2379
2594
 
2380
- private async handleScheduleFire(run: ScheduleRun, actorId: string | null): Promise<boolean> {
2595
+ private async handleScheduleFire(
2596
+ run: ScheduleRun,
2597
+ actorId: string | null,
2598
+ dispatchEventId?: string,
2599
+ ): Promise<boolean> {
2381
2600
  if (this.shuttingDown) return false; // drain window — let server requeue via catch-up
2382
2601
  const dedupeKey = `schedule_run:${run.id}`;
2383
2602
  if (this.dispatchedTasks.has(dedupeKey)) return false;
@@ -2400,6 +2619,7 @@ export class ParallAgentGateway {
2400
2619
  attachedUri: run.fired_attached_uri ?? undefined,
2401
2620
  ackSourceType: 'schedule_run',
2402
2621
  ackSourceId: run.id,
2622
+ dispatchEventId,
2403
2623
  };
2404
2624
 
2405
2625
  let dispatched: boolean;
@@ -2415,7 +2635,10 @@ export class ParallAgentGateway {
2415
2635
  return dispatched;
2416
2636
  }
2417
2637
 
2418
- private async fetchAndHandleExternalTriggerRun(runId: string): Promise<boolean> {
2638
+ private async fetchAndHandleExternalTriggerRun(
2639
+ runId: string,
2640
+ dispatchEventId?: string,
2641
+ ): Promise<boolean> {
2419
2642
  let run: ExternalTriggerRun | null = null;
2420
2643
  try {
2421
2644
  run = await this.opts.client.getExternalTriggerRun(this.opts.config.org_id, runId);
@@ -2433,7 +2656,7 @@ export class ParallAgentGateway {
2433
2656
  return false;
2434
2657
  }
2435
2658
  if (!run) return true;
2436
- return this.handleExternalTriggerRun(run);
2659
+ return this.handleExternalTriggerRun(run, dispatchEventId);
2437
2660
  }
2438
2661
 
2439
2662
  // fetchAndHandleChannelMessage resolves a channel_message dispatch to its
@@ -2441,7 +2664,10 @@ export class ParallAgentGateway {
2441
2664
  // pipeline. targetId = the ChannelConversation id, so per-conversation
2442
2665
  // multi-turn continuity rides the same per-target session mechanics as
2443
2666
  // chats. Design: docs/engineering-design/external-im-channel-design.md.
2444
- private async fetchAndHandleChannelMessage(messageId: string): Promise<boolean> {
2667
+ private async fetchAndHandleChannelMessage(
2668
+ messageId: string,
2669
+ dispatchEventId?: string,
2670
+ ): Promise<boolean> {
2445
2671
  if (this.shuttingDown) return false;
2446
2672
  // Capped dedupe (the chat-message path, not the unbounded task set): a busy
2447
2673
  // external IM conversation would otherwise retain one key per message ever
@@ -2525,6 +2751,7 @@ export class ParallAgentGateway {
2525
2751
  channelCliCapable: cliCapable,
2526
2752
  ackSourceType: 'channel_message',
2527
2753
  ackSourceId: msg.id,
2754
+ dispatchEventId,
2528
2755
  };
2529
2756
 
2530
2757
  // Release the claim if the event isn't actually dispatched (or throws) so
@@ -2542,7 +2769,10 @@ export class ParallAgentGateway {
2542
2769
  return dispatched;
2543
2770
  }
2544
2771
 
2545
- private async handleExternalTriggerRun(run: ExternalTriggerRun): Promise<boolean> {
2772
+ private async handleExternalTriggerRun(
2773
+ run: ExternalTriggerRun,
2774
+ dispatchEventId?: string,
2775
+ ): Promise<boolean> {
2546
2776
  if (this.shuttingDown) return false;
2547
2777
  const dedupeKey = `external_trigger_run:${run.id}`;
2548
2778
  if (this.dispatchedTasks.has(dedupeKey)) return false;
@@ -2570,6 +2800,7 @@ export class ParallAgentGateway {
2570
2800
  attachedUri,
2571
2801
  ackSourceType: 'external_trigger_run',
2572
2802
  ackSourceId: run.id,
2803
+ dispatchEventId,
2573
2804
  };
2574
2805
 
2575
2806
  let dispatched: boolean;
@@ -2589,6 +2820,7 @@ export class ParallAgentGateway {
2589
2820
  approvalId: string,
2590
2821
  actorId: string | null,
2591
2822
  chatId: string | null,
2823
+ dispatchEventId?: string,
2592
2824
  ): Promise<boolean> {
2593
2825
  let approval: Approval | null = null;
2594
2826
  try {
@@ -2625,6 +2857,11 @@ export class ParallAgentGateway {
2625
2857
  senderName: 'approver',
2626
2858
  messageId: approval.id,
2627
2859
  body,
2860
+ // The WorkItem's real source pair. Without it the legacy fallback
2861
+ // guessed ('message', approval_id) — a pair no row matches.
2862
+ ackSourceType: 'approval',
2863
+ ackSourceId: approval.id,
2864
+ dispatchEventId,
2628
2865
  };
2629
2866
 
2630
2867
  let dispatched: boolean;
@@ -2683,7 +2920,24 @@ export class ParallAgentGateway {
2683
2920
 
2684
2921
  if (overflowMode && processed >= CATCHUP_MAX) {
2685
2922
  try {
2686
- await this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id);
2923
+ // Administrative drop of the overflow backlog (summarized to the
2924
+ // agent instead of dispatched). Ledger path: by-id complete —
2925
+ // closes the pending row and refuses (stale) one a live lane
2926
+ // owns; the legacy ack stays as the ledger-disabled / pre-by-id
2927
+ // fallback.
2928
+ if (this.laneLedger && !this.ledgerDisabled && !this.typedByIdCompleteUnsupported) {
2929
+ const outcome = await this.resolveDispatchByID(item.id);
2930
+ if (outcome === 'unsupported') {
2931
+ this.typedByIdCompleteUnsupported = true;
2932
+ await this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id);
2933
+ } else if (outcome === 'failed') {
2934
+ throw new Error('by-id complete failed');
2935
+ }
2936
+ // 'stale' counts as skipped: a live lane owns the row and its
2937
+ // turn will resolve it — nothing left for catch-up to do.
2938
+ } else {
2939
+ await this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id);
2940
+ }
2687
2941
  skipped++;
2688
2942
  const key = item.event_type;
2689
2943
  skippedByType.set(key, (skippedByType.get(key) ?? 0) + 1);
@@ -2695,8 +2949,11 @@ export class ParallAgentGateway {
2695
2949
 
2696
2950
  processed++;
2697
2951
  try {
2698
- const ackItem = () =>
2699
- this.ackDispatchEvent(item.id, () => this.clearTypedDispatchDedupe(item));
2952
+ const typedHooks: TypedConsumeHooks = {
2953
+ legacyAck: () =>
2954
+ this.ackDispatchEvent(item.id, () => this.clearTypedDispatchDedupe(item)),
2955
+ clearDedupe: () => this.clearTypedDispatchDedupe(item),
2956
+ };
2700
2957
  if (item.event_type === 'task_assign' && item.task_id) {
2701
2958
  try {
2702
2959
  await this.consumeTypedDispatch(
@@ -2709,7 +2966,7 @@ export class ParallAgentGateway {
2709
2966
  dispatchEventId,
2710
2967
  },
2711
2968
  ),
2712
- ackItem,
2969
+ typedHooks,
2713
2970
  );
2714
2971
  } catch (err: unknown) {
2715
2972
  this.opts.log?.warn(
@@ -2730,7 +2987,7 @@ export class ParallAgentGateway {
2730
2987
  dispatchEventId,
2731
2988
  },
2732
2989
  ),
2733
- ackItem,
2990
+ typedHooks,
2734
2991
  );
2735
2992
  } catch (err: unknown) {
2736
2993
  this.opts.log?.warn(
@@ -2741,49 +2998,60 @@ export class ParallAgentGateway {
2741
2998
  } else if (item.event_type === 'task_comment' && item.source_id && item.task_id) {
2742
2999
  await this.consumeTypedDispatch(
2743
3000
  { dispatchEventId: item.id },
2744
- () =>
3001
+ (dispatchEventId) =>
2745
3002
  this.handleTaskComment(
2746
3003
  item.source_id,
2747
3004
  item.task_id ?? '',
2748
3005
  item.actor_id,
2749
3006
  item.delivery_reason,
3007
+ dispatchEventId,
2750
3008
  ),
2751
- ackItem,
3009
+ typedHooks,
2752
3010
  );
2753
3011
  } else if (item.event_type === 'wiki_comment' && item.source_id) {
2754
3012
  await this.consumeTypedDispatch(
2755
3013
  { dispatchEventId: item.id },
2756
- () => this.handleWikiComment(item.source_id, item.actor_id, item.delivery_reason),
2757
- ackItem,
3014
+ (dispatchEventId) =>
3015
+ this.handleWikiComment(
3016
+ item.source_id,
3017
+ item.actor_id,
3018
+ item.delivery_reason,
3019
+ dispatchEventId,
3020
+ ),
3021
+ typedHooks,
2758
3022
  );
2759
3023
  } else if (item.event_type === 'schedule.fire' && item.source_id) {
2760
3024
  await this.consumeTypedDispatch(
2761
3025
  { dispatchEventId: item.id },
2762
- () => this.fetchAndHandleScheduleFire(item.source_id, item.actor_id),
2763
- ackItem,
3026
+ (dispatchEventId) =>
3027
+ this.fetchAndHandleScheduleFire(item.source_id, item.actor_id, dispatchEventId),
3028
+ typedHooks,
2764
3029
  );
2765
3030
  } else if (item.event_type === 'external_trigger' && item.source_id) {
2766
3031
  await this.consumeTypedDispatch(
2767
3032
  { dispatchEventId: item.id },
2768
- () => this.fetchAndHandleExternalTriggerRun(item.source_id),
2769
- ackItem,
3033
+ (dispatchEventId) =>
3034
+ this.fetchAndHandleExternalTriggerRun(item.source_id, dispatchEventId),
3035
+ typedHooks,
2770
3036
  );
2771
3037
  } else if (item.event_type === 'channel_message' && item.source_id) {
2772
3038
  await this.consumeTypedDispatch(
2773
3039
  { dispatchEventId: item.id },
2774
- () => this.fetchAndHandleChannelMessage(item.source_id),
2775
- ackItem,
3040
+ (dispatchEventId) =>
3041
+ this.fetchAndHandleChannelMessage(item.source_id, dispatchEventId),
3042
+ typedHooks,
2776
3043
  );
2777
3044
  } else if (item.event_type === 'approval_decided' && item.source_id) {
2778
3045
  await this.consumeTypedDispatch(
2779
3046
  { dispatchEventId: item.id },
2780
- () =>
3047
+ (dispatchEventId) =>
2781
3048
  this.fetchAndHandleApprovalDecided(
2782
3049
  item.source_id,
2783
3050
  item.actor_id,
2784
3051
  item.chat_id ?? null,
3052
+ dispatchEventId,
2785
3053
  ),
2786
- ackItem,
3054
+ typedHooks,
2787
3055
  );
2788
3056
  } else if (item.event_type === 'message' && item.source_id && item.chat_id) {
2789
3057
  await this.consumeMessageWorkItem({