@parall/agent-core 1.44.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(
@@ -689,6 +733,26 @@ export class ParallAgentGateway {
689
733
  });
690
734
  }
691
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
+
692
756
  /** True when this event's lifecycle is owned by the dispatch lane ledger. */
693
757
  private usesLaneLedger(event: ParallEvent): boolean {
694
758
  return this.laneLedger != null && !this.ledgerDisabled && this.laneLedger.handles(event);
@@ -739,17 +803,18 @@ export class ParallAgentGateway {
739
803
  private consumeTypedDispatch(
740
804
  ref: { dispatchEventId?: string; sourceType?: string; sourceId?: string },
741
805
  run: (dispatchEventId?: string) => Promise<boolean>,
742
- ack: (dispatchEventId?: string) => boolean | void | Promise<boolean | void>,
806
+ hooks: TypedConsumeHooks,
743
807
  ): Promise<void> {
744
- return consumeTypedDispatch(this.laneFlowHost(), ref, run, ack);
808
+ return consumeTypedDispatch(this.laneFlowHost(), ref, run, hooks);
745
809
  }
746
810
 
747
- // Typed completion must wait until the administrative ack has either
748
- // committed or failed. Errors stay best-effort: a failed ack leaves the row
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.
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.
753
818
  private ackDispatchEvent(dispatchEventId: string, onFailure?: () => void): Promise<boolean> {
754
819
  return this.opts.client.ackDispatchByID(this.opts.config.org_id, dispatchEventId).then(
755
820
  () => true,
@@ -766,6 +831,9 @@ export class ParallAgentGateway {
766
831
  );
767
832
  }
768
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).
769
837
  private clearTypedDispatchDedupe(item: DispatchNewData): void {
770
838
  switch (item.event_type) {
771
839
  case 'task_assign':
@@ -1570,6 +1638,22 @@ export class ParallAgentGateway {
1570
1638
  earlier,
1571
1639
  batchText,
1572
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
+ }
1573
1657
  }
1574
1658
  if (!dispatched) {
1575
1659
  // Shutdown short-circuit — resolve un-acked so the server requeues
@@ -1755,15 +1839,22 @@ export class ParallAgentGateway {
1755
1839
  // no_action sweep); no legacy acks.
1756
1840
  continue;
1757
1841
  }
1758
- try {
1759
- await this.emitDispatchReceived(event);
1760
- } catch (err) {
1761
- this.opts.log?.warn?.(
1762
- `mark-received failed for buffered dispatch, leaving unacked for retry: ${String(err)}`,
1763
- );
1764
- this.dispatchState.mainBuffer.unshift(...events);
1765
- this.dispatchState.pendingForkResults.unshift(...pendingFork);
1766
- 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
+ }
1767
1858
  }
1768
1859
  const dispatched = await this.runDispatch(
1769
1860
  event,
@@ -1779,6 +1870,18 @@ export class ParallAgentGateway {
1779
1870
  this.dispatchState.pendingForkResults.unshift(...pendingFork);
1780
1871
  break;
1781
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
+ }
1782
1885
  for (const bufferedEvent of events) {
1783
1886
  const sourceType =
1784
1887
  bufferedEvent.ackSourceType ??
@@ -1866,16 +1969,24 @@ export class ParallAgentGateway {
1866
1969
  }
1867
1970
  return outcome === 'dispatched';
1868
1971
  }
1869
- try {
1870
- await this.emitDispatchReceived(event);
1871
- } catch (err) {
1872
- this.opts.log?.warn?.(`mark-received failed, leaving unacked for retry: ${String(err)}`);
1873
- this.dispatchState.mainDispatching = false;
1874
- this.dispatchState.mainCurrentTargetId = undefined;
1875
- this.mainCurrentGroupKey = undefined;
1876
- this.dispatchState.mainPreDispatchBranchPoint = undefined;
1877
- this.dispatchState.pendingForkResults.unshift(...pendingFork);
1878
- 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
+ }
1879
1990
  }
1880
1991
  let dispatched = false;
1881
1992
  try {
@@ -1884,6 +1995,17 @@ export class ParallAgentGateway {
1884
1995
  this.opts.runtimeKey,
1885
1996
  forkPrefix + buildEventBody(event),
1886
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
+ }
1887
2009
  if (!dispatched) {
1888
2010
  // Shutdown short-circuit — restore the fork results so a future
1889
2011
  // pod can replay them, and return false so handleMessage skips ack.
@@ -1973,8 +2095,9 @@ export class ParallAgentGateway {
1973
2095
  // — the fork's lane claim marks them received. A mark-received here
1974
2096
  // outruns the claim, strands the row ownerless, and the chat goes
1975
2097
  // 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)) {
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])) {
1978
2101
  try {
1979
2102
  await this.emitDispatchReceived(event);
1980
2103
  } catch (err) {
@@ -2185,10 +2308,13 @@ export class ParallAgentGateway {
2185
2308
  this.handleTaskDispatch(item.task_id ?? '', item.source_id ?? item.task_id ?? '', {
2186
2309
  dispatchEventId,
2187
2310
  }),
2188
- (dispatchEventId) =>
2189
- this.ackDispatchEvent(dispatchEventId ?? item.id, () =>
2190
- this.clearTypedDispatchDedupe(item),
2191
- ),
2311
+ {
2312
+ legacyAck: (dispatchEventId) =>
2313
+ this.ackDispatchEvent(dispatchEventId ?? item.id, () =>
2314
+ this.clearTypedDispatchDedupe(item),
2315
+ ),
2316
+ clearDedupe: () => this.clearTypedDispatchDedupe(item),
2317
+ },
2192
2318
  );
2193
2319
  }
2194
2320
 
@@ -2271,6 +2397,7 @@ export class ParallAgentGateway {
2271
2397
  taskId: string,
2272
2398
  actorId: string | null,
2273
2399
  deliveryReason?: DispatchDeliveryReason | null,
2400
+ dispatchEventId?: string,
2274
2401
  ): Promise<boolean> {
2275
2402
  if (this.shuttingDown) return false; // drain window — let server requeue via catch-up
2276
2403
  const dedupeKey = `comment:${commentId}`;
@@ -2335,6 +2462,7 @@ export class ParallAgentGateway {
2335
2462
  deliveryReason: deliveryReason ?? undefined,
2336
2463
  ackSourceType: 'comment',
2337
2464
  ackSourceId: commentId,
2465
+ dispatchEventId,
2338
2466
  };
2339
2467
 
2340
2468
  let dispatched: boolean;
@@ -2355,6 +2483,7 @@ export class ParallAgentGateway {
2355
2483
  commentId: string,
2356
2484
  actorId: string | null,
2357
2485
  deliveryReason?: DispatchDeliveryReason | null,
2486
+ dispatchEventId?: string,
2358
2487
  ): Promise<boolean> {
2359
2488
  if (this.shuttingDown) return false; // drain window — let server requeue via catch-up
2360
2489
  // Shares the comment dedupe namespace with handleTaskComment; comment IDs
@@ -2410,6 +2539,7 @@ export class ParallAgentGateway {
2410
2539
  replyTargetUri: comment.target_uri,
2411
2540
  ackSourceType: 'comment',
2412
2541
  ackSourceId: commentId,
2542
+ dispatchEventId,
2413
2543
  };
2414
2544
 
2415
2545
  let dispatched: boolean;
@@ -2437,6 +2567,7 @@ export class ParallAgentGateway {
2437
2567
  private async fetchAndHandleScheduleFire(
2438
2568
  runId: string,
2439
2569
  actorId: string | null,
2570
+ dispatchEventId?: string,
2440
2571
  ): Promise<boolean> {
2441
2572
  let run: ScheduleRun | null = null;
2442
2573
  try {
@@ -2458,10 +2589,14 @@ export class ParallAgentGateway {
2458
2589
  return false;
2459
2590
  }
2460
2591
  if (!run) return true;
2461
- return this.handleScheduleFire(run, actorId);
2592
+ return this.handleScheduleFire(run, actorId, dispatchEventId);
2462
2593
  }
2463
2594
 
2464
- 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> {
2465
2600
  if (this.shuttingDown) return false; // drain window — let server requeue via catch-up
2466
2601
  const dedupeKey = `schedule_run:${run.id}`;
2467
2602
  if (this.dispatchedTasks.has(dedupeKey)) return false;
@@ -2484,6 +2619,7 @@ export class ParallAgentGateway {
2484
2619
  attachedUri: run.fired_attached_uri ?? undefined,
2485
2620
  ackSourceType: 'schedule_run',
2486
2621
  ackSourceId: run.id,
2622
+ dispatchEventId,
2487
2623
  };
2488
2624
 
2489
2625
  let dispatched: boolean;
@@ -2499,7 +2635,10 @@ export class ParallAgentGateway {
2499
2635
  return dispatched;
2500
2636
  }
2501
2637
 
2502
- private async fetchAndHandleExternalTriggerRun(runId: string): Promise<boolean> {
2638
+ private async fetchAndHandleExternalTriggerRun(
2639
+ runId: string,
2640
+ dispatchEventId?: string,
2641
+ ): Promise<boolean> {
2503
2642
  let run: ExternalTriggerRun | null = null;
2504
2643
  try {
2505
2644
  run = await this.opts.client.getExternalTriggerRun(this.opts.config.org_id, runId);
@@ -2517,7 +2656,7 @@ export class ParallAgentGateway {
2517
2656
  return false;
2518
2657
  }
2519
2658
  if (!run) return true;
2520
- return this.handleExternalTriggerRun(run);
2659
+ return this.handleExternalTriggerRun(run, dispatchEventId);
2521
2660
  }
2522
2661
 
2523
2662
  // fetchAndHandleChannelMessage resolves a channel_message dispatch to its
@@ -2525,7 +2664,10 @@ export class ParallAgentGateway {
2525
2664
  // pipeline. targetId = the ChannelConversation id, so per-conversation
2526
2665
  // multi-turn continuity rides the same per-target session mechanics as
2527
2666
  // chats. Design: docs/engineering-design/external-im-channel-design.md.
2528
- private async fetchAndHandleChannelMessage(messageId: string): Promise<boolean> {
2667
+ private async fetchAndHandleChannelMessage(
2668
+ messageId: string,
2669
+ dispatchEventId?: string,
2670
+ ): Promise<boolean> {
2529
2671
  if (this.shuttingDown) return false;
2530
2672
  // Capped dedupe (the chat-message path, not the unbounded task set): a busy
2531
2673
  // external IM conversation would otherwise retain one key per message ever
@@ -2609,6 +2751,7 @@ export class ParallAgentGateway {
2609
2751
  channelCliCapable: cliCapable,
2610
2752
  ackSourceType: 'channel_message',
2611
2753
  ackSourceId: msg.id,
2754
+ dispatchEventId,
2612
2755
  };
2613
2756
 
2614
2757
  // Release the claim if the event isn't actually dispatched (or throws) so
@@ -2626,7 +2769,10 @@ export class ParallAgentGateway {
2626
2769
  return dispatched;
2627
2770
  }
2628
2771
 
2629
- private async handleExternalTriggerRun(run: ExternalTriggerRun): Promise<boolean> {
2772
+ private async handleExternalTriggerRun(
2773
+ run: ExternalTriggerRun,
2774
+ dispatchEventId?: string,
2775
+ ): Promise<boolean> {
2630
2776
  if (this.shuttingDown) return false;
2631
2777
  const dedupeKey = `external_trigger_run:${run.id}`;
2632
2778
  if (this.dispatchedTasks.has(dedupeKey)) return false;
@@ -2654,6 +2800,7 @@ export class ParallAgentGateway {
2654
2800
  attachedUri,
2655
2801
  ackSourceType: 'external_trigger_run',
2656
2802
  ackSourceId: run.id,
2803
+ dispatchEventId,
2657
2804
  };
2658
2805
 
2659
2806
  let dispatched: boolean;
@@ -2673,6 +2820,7 @@ export class ParallAgentGateway {
2673
2820
  approvalId: string,
2674
2821
  actorId: string | null,
2675
2822
  chatId: string | null,
2823
+ dispatchEventId?: string,
2676
2824
  ): Promise<boolean> {
2677
2825
  let approval: Approval | null = null;
2678
2826
  try {
@@ -2709,6 +2857,11 @@ export class ParallAgentGateway {
2709
2857
  senderName: 'approver',
2710
2858
  messageId: approval.id,
2711
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,
2712
2865
  };
2713
2866
 
2714
2867
  let dispatched: boolean;
@@ -2767,7 +2920,24 @@ export class ParallAgentGateway {
2767
2920
 
2768
2921
  if (overflowMode && processed >= CATCHUP_MAX) {
2769
2922
  try {
2770
- 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
+ }
2771
2941
  skipped++;
2772
2942
  const key = item.event_type;
2773
2943
  skippedByType.set(key, (skippedByType.get(key) ?? 0) + 1);
@@ -2779,8 +2949,11 @@ export class ParallAgentGateway {
2779
2949
 
2780
2950
  processed++;
2781
2951
  try {
2782
- const ackItem = () =>
2783
- 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
+ };
2784
2957
  if (item.event_type === 'task_assign' && item.task_id) {
2785
2958
  try {
2786
2959
  await this.consumeTypedDispatch(
@@ -2793,7 +2966,7 @@ export class ParallAgentGateway {
2793
2966
  dispatchEventId,
2794
2967
  },
2795
2968
  ),
2796
- ackItem,
2969
+ typedHooks,
2797
2970
  );
2798
2971
  } catch (err: unknown) {
2799
2972
  this.opts.log?.warn(
@@ -2814,7 +2987,7 @@ export class ParallAgentGateway {
2814
2987
  dispatchEventId,
2815
2988
  },
2816
2989
  ),
2817
- ackItem,
2990
+ typedHooks,
2818
2991
  );
2819
2992
  } catch (err: unknown) {
2820
2993
  this.opts.log?.warn(
@@ -2825,49 +2998,60 @@ export class ParallAgentGateway {
2825
2998
  } else if (item.event_type === 'task_comment' && item.source_id && item.task_id) {
2826
2999
  await this.consumeTypedDispatch(
2827
3000
  { dispatchEventId: item.id },
2828
- () =>
3001
+ (dispatchEventId) =>
2829
3002
  this.handleTaskComment(
2830
3003
  item.source_id,
2831
3004
  item.task_id ?? '',
2832
3005
  item.actor_id,
2833
3006
  item.delivery_reason,
3007
+ dispatchEventId,
2834
3008
  ),
2835
- ackItem,
3009
+ typedHooks,
2836
3010
  );
2837
3011
  } else if (item.event_type === 'wiki_comment' && item.source_id) {
2838
3012
  await this.consumeTypedDispatch(
2839
3013
  { dispatchEventId: item.id },
2840
- () => this.handleWikiComment(item.source_id, item.actor_id, item.delivery_reason),
2841
- ackItem,
3014
+ (dispatchEventId) =>
3015
+ this.handleWikiComment(
3016
+ item.source_id,
3017
+ item.actor_id,
3018
+ item.delivery_reason,
3019
+ dispatchEventId,
3020
+ ),
3021
+ typedHooks,
2842
3022
  );
2843
3023
  } else if (item.event_type === 'schedule.fire' && item.source_id) {
2844
3024
  await this.consumeTypedDispatch(
2845
3025
  { dispatchEventId: item.id },
2846
- () => this.fetchAndHandleScheduleFire(item.source_id, item.actor_id),
2847
- ackItem,
3026
+ (dispatchEventId) =>
3027
+ this.fetchAndHandleScheduleFire(item.source_id, item.actor_id, dispatchEventId),
3028
+ typedHooks,
2848
3029
  );
2849
3030
  } else if (item.event_type === 'external_trigger' && item.source_id) {
2850
3031
  await this.consumeTypedDispatch(
2851
3032
  { dispatchEventId: item.id },
2852
- () => this.fetchAndHandleExternalTriggerRun(item.source_id),
2853
- ackItem,
3033
+ (dispatchEventId) =>
3034
+ this.fetchAndHandleExternalTriggerRun(item.source_id, dispatchEventId),
3035
+ typedHooks,
2854
3036
  );
2855
3037
  } else if (item.event_type === 'channel_message' && item.source_id) {
2856
3038
  await this.consumeTypedDispatch(
2857
3039
  { dispatchEventId: item.id },
2858
- () => this.fetchAndHandleChannelMessage(item.source_id),
2859
- ackItem,
3040
+ (dispatchEventId) =>
3041
+ this.fetchAndHandleChannelMessage(item.source_id, dispatchEventId),
3042
+ typedHooks,
2860
3043
  );
2861
3044
  } else if (item.event_type === 'approval_decided' && item.source_id) {
2862
3045
  await this.consumeTypedDispatch(
2863
3046
  { dispatchEventId: item.id },
2864
- () =>
3047
+ (dispatchEventId) =>
2865
3048
  this.fetchAndHandleApprovalDecided(
2866
3049
  item.source_id,
2867
3050
  item.actor_id,
2868
3051
  item.chat_id ?? null,
3052
+ dispatchEventId,
2869
3053
  ),
2870
- ackItem,
3054
+ typedHooks,
2871
3055
  );
2872
3056
  } else if (item.event_type === 'message' && item.source_id && item.chat_id) {
2873
3057
  await this.consumeMessageWorkItem({