@parall/agent-core 1.36.1 → 1.38.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 (46) hide show
  1. package/dist/bridge-workspace.d.ts +1 -1
  2. package/dist/bridge-workspace.d.ts.map +1 -1
  3. package/dist/bridge-workspace.js +13 -3
  4. package/dist/dispatch-adapter.d.ts +6 -0
  5. package/dist/dispatch-adapter.d.ts.map +1 -1
  6. package/dist/event-format.d.ts.map +1 -1
  7. package/dist/event-format.js +36 -1
  8. package/dist/gateway-base.d.ts +56 -0
  9. package/dist/gateway-base.d.ts.map +1 -1
  10. package/dist/gateway-base.js +459 -97
  11. package/dist/gateway-lane-flow.d.ts +74 -0
  12. package/dist/gateway-lane-flow.d.ts.map +1 -0
  13. package/dist/gateway-lane-flow.js +167 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1 -0
  17. package/dist/lane-key.d.ts +45 -0
  18. package/dist/lane-key.d.ts.map +1 -0
  19. package/dist/lane-key.js +34 -0
  20. package/dist/lane-ledger.d.ts +112 -0
  21. package/dist/lane-ledger.d.ts.map +1 -0
  22. package/dist/lane-ledger.js +333 -0
  23. package/dist/platform-config.d.ts +19 -0
  24. package/dist/platform-config.d.ts.map +1 -1
  25. package/dist/platform-config.js +72 -9
  26. package/dist/prompt-fragments.d.ts +1 -1
  27. package/dist/prompt-fragments.d.ts.map +1 -1
  28. package/dist/prompt-fragments.js +2 -0
  29. package/dist/skills/parall-platform.d.ts +1 -1
  30. package/dist/skills/parall-platform.d.ts.map +1 -1
  31. package/dist/skills/parall-platform.js +27 -6
  32. package/dist/types.d.ts +11 -2
  33. package/dist/types.d.ts.map +1 -1
  34. package/package.json +2 -2
  35. package/src/bridge-workspace.ts +13 -3
  36. package/src/dispatch-adapter.ts +6 -0
  37. package/src/event-format.ts +38 -1
  38. package/src/gateway-base.ts +637 -143
  39. package/src/gateway-lane-flow.ts +235 -0
  40. package/src/index.ts +2 -0
  41. package/src/lane-key.ts +67 -0
  42. package/src/lane-ledger.ts +370 -0
  43. package/src/platform-config.ts +85 -9
  44. package/src/prompt-fragments.ts +2 -0
  45. package/src/skills/parall-platform.ts +27 -6
  46. package/src/types.ts +17 -1
@@ -7,6 +7,8 @@ import type {
7
7
  AgentNewSessionData,
8
8
  AgentSessionDB,
9
9
  Approval,
10
+ ChannelConversation,
11
+ ChannelMessage,
10
12
  Chat,
11
13
  ChatUpdateData,
12
14
  Comment,
@@ -34,6 +36,13 @@ import type {
34
36
  GatewayLogger,
35
37
  RuntimeEvent,
36
38
  } from './dispatch-adapter.js';
39
+ import {
40
+ consumeMessageWorkItem,
41
+ consumeTypedDispatch,
42
+ dispatchLaneGroup,
43
+ } from './gateway-lane-flow.js';
44
+ import type { LaneFlowHost } from './gateway-lane-flow.js';
45
+ import { LaneLedger } from './lane-ledger.js';
37
46
  import { routeTrigger } from './routing.js';
38
47
  import {
39
48
  clearDispatchMessageId,
@@ -87,7 +96,7 @@ type ActiveForkState = {
87
96
  deadlineExceeded: boolean;
88
97
  };
89
98
 
90
- type DispatchableMessage = {
99
+ export type DispatchableMessage = {
91
100
  id: string;
92
101
  sender_id: string;
93
102
  sender?: { display_name?: string | null };
@@ -99,7 +108,7 @@ type DispatchableMessage = {
99
108
  created_at?: string;
100
109
  };
101
110
 
102
- type MessageDispatchDecision =
111
+ export type MessageDispatchDecision =
103
112
  | { action: 'dispatch'; event: ParallEvent }
104
113
  | { action: 'skip' }
105
114
  | { action: 'retry' };
@@ -131,6 +140,13 @@ export type ParallGatewayOptions = {
131
140
  contextFilePathForSession?: (sessionKey: string) => string | undefined;
132
141
  /** @deprecated Use contextFilePathForSession. Kept for runtimes that haven't migrated. */
133
142
  stepIdFilePathForSession?: (sessionKey: string) => string | undefined;
143
+ /**
144
+ * PRLL_CONTEXT_DIR contract root (per-agent stateDir, never the workspace).
145
+ * When set, chat-message dispatches ride the server dispatch ledger
146
+ * (claim → steer → complete) and per-lane context files are written under
147
+ * this directory. Absent → legacy received/ack flow (openclaw / hermes).
148
+ */
149
+ dispatchContextDir?: string;
134
150
  onConfigUpdate?: (data: AgentConfigUpdateData) => Promise<void> | void;
135
151
  onSessionReady?: (state: {
136
152
  activeSessionId?: string;
@@ -198,6 +214,9 @@ function resolveStepTarget(event: ParallEvent): { target_type: string; target_id
198
214
  if (event.type === 'external_trigger' || event.targetId.startsWith('xtr_')) {
199
215
  return { target_type: 'external_trigger', target_id: event.targetId };
200
216
  }
217
+ if (event.type === 'channel_message' || event.targetId.startsWith('chv_')) {
218
+ return { target_type: 'channel_conversation', target_id: event.targetId };
219
+ }
201
220
  if (event.type === 'wiki_comment') {
202
221
  // target_id is the full wiki target_uri (scheme-stripped routing key). The
203
222
  // server stores target_type freely and only publishes step WS events /
@@ -269,6 +288,9 @@ async function fetchAllChats(
269
288
  export class ParallAgentGateway {
270
289
  private readonly chatInfoMap = new Map<string, ChatInfo>();
271
290
  private readonly dispatchedTasks = new Set<string>();
291
+ // connection id → provider alias, for channel_message prompt labeling
292
+ // (stable mapping; avoids one connection fetch per inbound message).
293
+ private readonly channelConnectionProviders = new Map<string, string>();
272
294
  private readonly dispatchedMessages = new Set<string>();
273
295
  private readonly forkStates = new Map<string, ActiveForkState>();
274
296
  private readonly dispatchState: DispatchState = {
@@ -295,6 +317,15 @@ export class ParallAgentGateway {
295
317
  private drainResolvers: Array<() => void> = [];
296
318
  private pendingRestartNotification: string | null = null;
297
319
 
320
+ private readonly laneLedger?: LaneLedger;
321
+ // Sticky fallback: flipped when the server predates the ledger (claim
322
+ // endpoint 404) so every subsequent dispatch uses the legacy flow.
323
+ private ledgerDisabled = false;
324
+ // Group key of the group currently being dispatched on main — lane-aware
325
+ // (targetId + thread), unlike mainCurrentTargetId which stays chat-level
326
+ // for fork routing decisions.
327
+ private mainCurrentGroupKey?: string;
328
+
298
329
  private readonly DISPATCHED_MESSAGES_CAP = 5000;
299
330
  // SHUTDOWN_DEADLINE_MS is read by waitForDrain via the configured value
300
331
  // below — kept as instance state so per-runtime configs can override it
@@ -304,6 +335,14 @@ export class ParallAgentGateway {
304
335
  private readonly DISPATCH_DEADLINE_MS: number;
305
336
 
306
337
  constructor(private readonly opts: ParallGatewayOptions) {
338
+ if (opts.dispatchContextDir) {
339
+ this.laneLedger = new LaneLedger({
340
+ client: opts.client,
341
+ orgId: opts.config.org_id,
342
+ contextDir: opts.dispatchContextDir,
343
+ log: opts.log,
344
+ });
345
+ }
307
346
  this.SHUTDOWN_DEADLINE_MS = opts.shutdownDeadlineMs ?? 60_000;
308
347
  this.FORK_DEADLINE_MS = opts.forkDeadlineMs ?? 2 * 60 * 60_000;
309
348
  this.DISPATCH_DEADLINE_MS = opts.dispatchDeadlineMs ?? 20 * 60_000;
@@ -387,15 +426,30 @@ export class ParallAgentGateway {
387
426
  if (data.assignee_id !== this.opts.agentUserId) return;
388
427
  if (data.status !== 'todo' && data.status !== 'in_progress') return;
389
428
  try {
390
- const dispatched = await this.handleTaskAssignment(data, data.id);
391
- if (dispatched) {
392
- this.opts.client
393
- .ackDispatch(this.opts.config.org_id, {
394
- source_type: 'task_activity',
395
- source_id: data.id,
396
- })
397
- .catch(() => {});
398
- }
429
+ // Prefer the exact WorkItem id the server threads through the event —
430
+ // a task PATCH can enqueue sibling task_assign + task_update rows
431
+ // under the same (task_activity, task_id) source tuple, and source-
432
+ // level claim/ack would consume or clear the wrong sibling.
433
+ await this.consumeTypedDispatch(
434
+ data.dispatch_event_id
435
+ ? { dispatchEventId: data.dispatch_event_id }
436
+ : { sourceType: 'task_activity', sourceId: data.id },
437
+ (dispatchEventId) => this.handleTaskAssignment(data, data.id, dispatchEventId),
438
+ (dispatchEventId) => {
439
+ if (dispatchEventId) {
440
+ this.opts.client
441
+ .ackDispatchByID(this.opts.config.org_id, dispatchEventId)
442
+ .catch(() => {});
443
+ return;
444
+ }
445
+ this.opts.client
446
+ .ackDispatch(this.opts.config.org_id, {
447
+ source_type: 'task_activity',
448
+ source_id: data.id,
449
+ })
450
+ .catch(() => {});
451
+ },
452
+ );
399
453
  } catch (err) {
400
454
  this.opts.log?.error(`task dispatch failed for ${data.id}: ${String(err)}`);
401
455
  }
@@ -405,15 +459,19 @@ export class ParallAgentGateway {
405
459
  if (data.event_type === 'task_comment') {
406
460
  if (!data.source_id || !data.task_id) return;
407
461
  try {
408
- const dispatched = await this.handleTaskComment(
409
- data.source_id,
410
- data.task_id,
411
- data.actor_id,
412
- data.delivery_reason,
462
+ await this.consumeTypedDispatch(
463
+ { dispatchEventId: data.id },
464
+ () =>
465
+ this.handleTaskComment(
466
+ data.source_id,
467
+ data.task_id ?? '',
468
+ data.actor_id,
469
+ data.delivery_reason,
470
+ ),
471
+ () => {
472
+ this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
473
+ },
413
474
  );
414
- if (dispatched) {
415
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
416
- }
417
475
  } catch (err) {
418
476
  this.opts.log?.error(
419
477
  `task comment dispatch failed for ${data.source_id}: ${String(err)}`,
@@ -422,14 +480,13 @@ export class ParallAgentGateway {
422
480
  } else if (data.event_type === 'wiki_comment') {
423
481
  if (!data.source_id) return;
424
482
  try {
425
- const dispatched = await this.handleWikiComment(
426
- data.source_id,
427
- data.actor_id,
428
- data.delivery_reason,
483
+ await this.consumeTypedDispatch(
484
+ { dispatchEventId: data.id },
485
+ () => this.handleWikiComment(data.source_id, data.actor_id, data.delivery_reason),
486
+ () => {
487
+ this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
488
+ },
429
489
  );
430
- if (dispatched) {
431
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
432
- }
433
490
  } catch (err) {
434
491
  this.opts.log?.error(
435
492
  `wiki comment dispatch failed for ${data.source_id}: ${String(err)}`,
@@ -438,24 +495,30 @@ export class ParallAgentGateway {
438
495
  } else if (data.event_type === 'task_update') {
439
496
  if (!data.task_id) return;
440
497
  try {
441
- const dispatched = await this.handleTaskDispatch(
442
- data.task_id,
443
- data.source_id ?? data.task_id,
444
- { allowCreator: true },
498
+ await this.consumeTypedDispatch(
499
+ { dispatchEventId: data.id },
500
+ (dispatchEventId) =>
501
+ this.handleTaskDispatch(data.task_id ?? '', data.source_id ?? data.task_id ?? '', {
502
+ allowCreator: true,
503
+ dispatchEventId,
504
+ }),
505
+ () => {
506
+ this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
507
+ },
445
508
  );
446
- if (dispatched) {
447
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
448
- }
449
509
  } catch (err) {
450
510
  this.opts.log?.error(`task update dispatch failed for ${data.task_id}: ${String(err)}`);
451
511
  }
452
512
  } else if (data.event_type === 'schedule.fire') {
453
513
  if (!data.source_id) return;
454
514
  try {
455
- const dispatched = await this.fetchAndHandleScheduleFire(data.source_id, data.actor_id);
456
- if (dispatched) {
457
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
458
- }
515
+ await this.consumeTypedDispatch(
516
+ { dispatchEventId: data.id },
517
+ () => this.fetchAndHandleScheduleFire(data.source_id, data.actor_id),
518
+ () => {
519
+ this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
520
+ },
521
+ );
459
522
  } catch (err) {
460
523
  this.opts.log?.error(
461
524
  `schedule fire dispatch failed for ${data.source_id}: ${String(err)}`,
@@ -464,31 +527,67 @@ export class ParallAgentGateway {
464
527
  } else if (data.event_type === 'external_trigger') {
465
528
  if (!data.source_id) return;
466
529
  try {
467
- const dispatched = await this.fetchAndHandleExternalTriggerRun(data.source_id);
468
- if (dispatched) {
469
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
470
- }
530
+ await this.consumeTypedDispatch(
531
+ { dispatchEventId: data.id },
532
+ () => this.fetchAndHandleExternalTriggerRun(data.source_id),
533
+ () => {
534
+ this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
535
+ },
536
+ );
471
537
  } catch (err) {
472
538
  this.opts.log?.error(
473
539
  `external trigger dispatch failed for ${data.source_id}: ${String(err)}`,
474
540
  );
475
541
  }
542
+ } else if (data.event_type === 'channel_message') {
543
+ if (!data.source_id) return;
544
+ try {
545
+ await this.consumeTypedDispatch(
546
+ { dispatchEventId: data.id },
547
+ () => this.fetchAndHandleChannelMessage(data.source_id),
548
+ () => {
549
+ this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
550
+ },
551
+ );
552
+ } catch (err) {
553
+ this.opts.log?.error(
554
+ `channel message dispatch failed for ${data.source_id}: ${String(err)}`,
555
+ );
556
+ }
476
557
  } else if (data.event_type === 'approval_decided') {
477
558
  if (!data.source_id) return;
478
559
  try {
479
- const dispatched = await this.fetchAndHandleApprovalDecided(
480
- data.source_id,
481
- data.actor_id,
482
- data.chat_id ?? null,
560
+ await this.consumeTypedDispatch(
561
+ { dispatchEventId: data.id },
562
+ () =>
563
+ this.fetchAndHandleApprovalDecided(
564
+ data.source_id,
565
+ data.actor_id,
566
+ data.chat_id ?? null,
567
+ ),
568
+ () => {
569
+ this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
570
+ },
483
571
  );
484
- if (dispatched) {
485
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
486
- }
487
572
  } catch (err) {
488
573
  this.opts.log?.error(
489
574
  `approval decided dispatch failed for ${data.source_id}: ${String(err)}`,
490
575
  );
491
576
  }
577
+ } else if (
578
+ data.event_type === 'message' &&
579
+ this.laneLedger &&
580
+ data.source_id &&
581
+ data.chat_id
582
+ ) {
583
+ // Ledger re-drive hint: a pending message WorkItem re-published after
584
+ // a same-target lane completed (claim previously refused, or a steer
585
+ // failed). Live first delivery stays on the message.new handler.
586
+ try {
587
+ await this.handleMessageRedrive(data);
588
+ } catch (err) {
589
+ this.opts.log?.error(`message re-drive failed for ${data.source_id}: ${String(err)}`);
590
+ }
492
591
  } else if (data.event_type !== 'message' && data.event_type !== 'task_assign') {
493
592
  // Truly unknown event_type — log so a newly-added dispatch type
494
593
  // not yet wired here surfaces during runtime testing. "message"
@@ -536,6 +635,61 @@ export class ParallAgentGateway {
536
635
  });
537
636
  }
538
637
 
638
+ /** True when this event's lifecycle is owned by the dispatch lane ledger. */
639
+ private usesLaneLedger(event: ParallEvent): boolean {
640
+ return this.laneLedger != null && !this.ledgerDisabled && this.laneLedger.handles(event);
641
+ }
642
+
643
+ private disableLedger(reason: string) {
644
+ if (this.ledgerDisabled) return;
645
+ this.ledgerDisabled = true;
646
+ this.opts.log?.warn(
647
+ `dispatch ledger unavailable (${reason}) — falling back to legacy received/ack flow`,
648
+ );
649
+ }
650
+
651
+ /**
652
+ * Buffer grouping key. Lane-ledger message events group by full lane
653
+ * identity (chat + thread) so a channel lane and a thread lane in the same
654
+ * chat dispatch as separate turns with separate claims; everything else
655
+ * keeps the historical chat-level grouping.
656
+ */
657
+ private dispatchGroupKey(event: ParallEvent): string {
658
+ if (this.usesLaneLedger(event)) {
659
+ // MUST be the lane identity itself (lane-key SSOT): the mid-turn
660
+ // injection gate compares this against mainCurrentGroupKey, and a
661
+ // grouping key that drifted from lane identity would fold two lanes
662
+ // into one turn.
663
+ return this.laneLedger!.laneKeyFor(event);
664
+ }
665
+ return event.targetId;
666
+ }
667
+
668
+ // Lane-flow protocols live in gateway-lane-flow.ts; these thin delegates
669
+ // keep call sites and tests on the class surface.
670
+ private laneFlowHost(): LaneFlowHost {
671
+ return this as unknown as LaneFlowHost;
672
+ }
673
+
674
+ private dispatchLaneGroup(opts: {
675
+ events: ParallEvent[];
676
+ sessionKey: string;
677
+ body: string;
678
+ earlier: ParallEvent[];
679
+ captureText?: string[];
680
+ hasMoreLocal: () => boolean;
681
+ }): Promise<'dispatched' | 'foreign' | 'shutdown'> {
682
+ return dispatchLaneGroup(this.laneFlowHost(), opts);
683
+ }
684
+
685
+ private consumeTypedDispatch(
686
+ ref: { dispatchEventId?: string; sourceType?: string; sourceId?: string },
687
+ run: (dispatchEventId?: string) => Promise<boolean>,
688
+ ack: (dispatchEventId?: string) => void,
689
+ ): Promise<void> {
690
+ return consumeTypedDispatch(this.laneFlowHost(), ref, run, ack);
691
+ }
692
+
539
693
  private buildDispatchContext(event: ParallEvent, sessionKey: string): DispatchContext {
540
694
  const binding = this.sessionBindings.get(sessionKey);
541
695
  return {
@@ -552,6 +706,7 @@ export class ParallAgentGateway {
552
706
  noReply: event.noReply ?? false,
553
707
  contextFilePath: this.opts.contextFilePathForSession?.(sessionKey),
554
708
  stepIdFilePath: this.opts.stepIdFilePathForSession?.(sessionKey),
709
+ contextDirPath: this.opts.dispatchContextDir,
555
710
  client: this.opts.client,
556
711
  log: this.opts.log,
557
712
  };
@@ -588,9 +743,11 @@ export class ParallAgentGateway {
588
743
  ? 'schedule_fire'
589
744
  : event.type === 'external_trigger'
590
745
  ? 'external_trigger'
591
- : event.type === 'approval'
592
- ? 'approval_decided'
593
- : 'mention',
746
+ : event.type === 'channel_message'
747
+ ? 'channel_message'
748
+ : event.type === 'approval'
749
+ ? 'approval_decided'
750
+ : 'mention',
594
751
  trigger_ref:
595
752
  event.type === 'task'
596
753
  ? { task_id: event.targetId }
@@ -607,9 +764,16 @@ export class ParallAgentGateway {
607
764
  connection_id: event.externalConnectionId,
608
765
  ingress_event_id: event.externalIngressEventId,
609
766
  }
610
- : event.type === 'approval'
611
- ? { approval_id: event.messageId }
612
- : { message_id: event.messageId },
767
+ : event.type === 'channel_message'
768
+ ? {
769
+ conversation_id: event.targetId,
770
+ channel_message_id: event.messageId,
771
+ provider: event.channelProvider,
772
+ external_conversation_id: event.channelExternalConversationId,
773
+ }
774
+ : event.type === 'approval'
775
+ ? { approval_id: event.messageId }
776
+ : { message_id: event.messageId },
613
777
  sender_id: event.senderId,
614
778
  sender_name: event.senderName,
615
779
  summary: event.body.substring(0, 200),
@@ -629,6 +793,7 @@ export class ParallAgentGateway {
629
793
  runtimeEvent: RuntimeEvent,
630
794
  stepIdFilePath?: string,
631
795
  contextFilePath?: string,
796
+ laneContextFilePath?: string,
632
797
  ) {
633
798
  const target = resolveStepTarget(event);
634
799
  try {
@@ -692,6 +857,9 @@ export class ParallAgentGateway {
692
857
  } else if (stepIdFilePath) {
693
858
  this.writeStepIdFile(stepIdFilePath, step.id);
694
859
  }
860
+ if (laneContextFilePath) {
861
+ this.updateContextFileStepId(laneContextFilePath, step.id);
862
+ }
695
863
  break;
696
864
  }
697
865
 
@@ -720,6 +888,9 @@ export class ParallAgentGateway {
720
888
  } else if (stepIdFilePath) {
721
889
  this.clearStepIdFile(stepIdFilePath);
722
890
  }
891
+ if (laneContextFilePath) {
892
+ this.updateContextFileStepId(laneContextFilePath, null);
893
+ }
723
894
  break;
724
895
 
725
896
  case 'error':
@@ -803,6 +974,7 @@ export class ParallAgentGateway {
803
974
  sessionKey: string,
804
975
  runtimeEvent: Extract<RuntimeEvent, { type: 'runtime_session' }>,
805
976
  contextFilePath?: string,
977
+ laneContextFilePath?: string,
806
978
  ): Promise<AgentSessionBinding> {
807
979
  const runtimeLaneKey = runtimeEvent.runtimeLaneKey || sessionKey;
808
980
  const existing = this.sessionBindings.get(sessionKey);
@@ -864,6 +1036,9 @@ export class ParallAgentGateway {
864
1036
  if (contextFilePath) {
865
1037
  this.updateContextFileSessionId(contextFilePath, session.id);
866
1038
  }
1039
+ if (laneContextFilePath) {
1040
+ this.updateContextFileSessionId(laneContextFilePath, session.id);
1041
+ }
867
1042
  await this.opts.onSessionBinding?.(binding);
868
1043
  return binding;
869
1044
  }
@@ -907,14 +1082,33 @@ export class ParallAgentGateway {
907
1082
  const contextFilePath = dispatchContext.contextFilePath;
908
1083
  const stepIdFilePath = dispatchContext.stepIdFilePath;
909
1084
 
1085
+ // Per-lane context (PRLL_CONTEXT_DIR contract): additive dispatch/lane
1086
+ // fields ride along in both files; the per-session file stays as the
1087
+ // PRLL_CONTEXT_FILE compat read path.
1088
+ const activeLane = this.ledgerDisabled ? undefined : this.laneLedger?.getForEvent(event);
1089
+ const laneContextFilePath = activeLane
1090
+ ? this.laneLedger?.laneContextPath(activeLane)
1091
+ : undefined;
1092
+ const contextBody = {
1093
+ session_id: dispatchContext.sessionId ?? null,
1094
+ chat_id: dispatchContext.chatId ?? null,
1095
+ trigger_message_id: dispatchContext.triggerMessageId ?? null,
1096
+ no_reply: dispatchContext.noReply,
1097
+ step_id: null,
1098
+ dispatch_event_id:
1099
+ activeLane?.typedDispatchEventId ?? activeLane?.folded.get(event.messageId) ?? null,
1100
+ lane: activeLane?.lane ?? null,
1101
+ target_uri: activeLane?.targetUri ?? null,
1102
+ thread_root_id: activeLane?.threadRootId ?? null,
1103
+ // Typed binding hint for the CLI: which task this dispatch is about
1104
+ // (parall task update attaches the typed effect only on a match).
1105
+ task_id: event.type === 'task' ? event.targetId : null,
1106
+ };
910
1107
  if (contextFilePath) {
911
- this.writeContextFile(contextFilePath, {
912
- session_id: dispatchContext.sessionId ?? null,
913
- chat_id: dispatchContext.chatId ?? null,
914
- trigger_message_id: dispatchContext.triggerMessageId ?? null,
915
- no_reply: dispatchContext.noReply,
916
- step_id: null,
917
- });
1108
+ this.writeContextFile(contextFilePath, contextBody);
1109
+ }
1110
+ if (laneContextFilePath) {
1111
+ this.writeContextFile(laneContextFilePath, contextBody);
918
1112
  }
919
1113
 
920
1114
  // sync: no await between the shuttingDown check above and this increment
@@ -951,7 +1145,32 @@ export class ParallAgentGateway {
951
1145
  context: dispatchContext,
952
1146
  })) {
953
1147
  if (runtimeEvent.type === 'runtime_session') {
954
- binding = await this.bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath);
1148
+ const priorAgentSessionId = binding?.agentSessionId;
1149
+ binding = await this.bindRuntimeSession(
1150
+ sessionKey,
1151
+ runtimeEvent,
1152
+ contextFilePath,
1153
+ laneContextFilePath,
1154
+ );
1155
+ if (
1156
+ event.targetType === 'channel_conversation' &&
1157
+ binding.agentSessionId !== priorAgentSessionId
1158
+ ) {
1159
+ // Record the durable chv_ ↔ ase_ mapping (ops drill-down from a
1160
+ // conversation into its session). Best-effort bookkeeping —
1161
+ // never fail the dispatch over it.
1162
+ try {
1163
+ await this.opts.client.setChannelConversationSession(
1164
+ this.opts.config.org_id,
1165
+ event.targetId,
1166
+ binding.agentSessionId,
1167
+ );
1168
+ } catch (err) {
1169
+ this.opts.log?.warn(
1170
+ `failed to record session mapping for channel conversation ${event.targetId}: ${String(err)}`,
1171
+ );
1172
+ }
1173
+ }
955
1174
  if (!inputStepsCreated) {
956
1175
  // Persist input steps for "earlier events" (batched events that arrived
957
1176
  // while a dispatch was in flight) inside the in-flight window so a
@@ -988,6 +1207,12 @@ export class ParallAgentGateway {
988
1207
  await this.createInputStep(binding.agentSessionId, event);
989
1208
  inputStepsCreated = true;
990
1209
  }
1210
+ // Long-turn keepalive: any runtime activity renews the lane lease
1211
+ // (throttled in the ledger) so a legitimately long turn is not
1212
+ // dethroned at TTL.
1213
+ if (activeLane && !this.ledgerDisabled) {
1214
+ this.laneLedger?.maybeRenew(activeLane);
1215
+ }
991
1216
  if (captureText && runtimeEvent.type === 'text' && runtimeEvent.text) {
992
1217
  captureText.push(runtimeEvent.text);
993
1218
  }
@@ -1013,6 +1238,7 @@ export class ParallAgentGateway {
1013
1238
  runtimeEvent,
1014
1239
  stepIdFilePath,
1015
1240
  contextFilePath,
1241
+ laneContextFilePath,
1016
1242
  );
1017
1243
  }
1018
1244
  if (!binding) {
@@ -1041,6 +1267,7 @@ export class ParallAgentGateway {
1041
1267
  },
1042
1268
  stepIdFilePath,
1043
1269
  contextFilePath,
1270
+ laneContextFilePath,
1044
1271
  );
1045
1272
  } catch (stepErr) {
1046
1273
  if (this.isSessionNotLiveError(stepErr)) staleDetected = true;
@@ -1105,6 +1332,9 @@ export class ParallAgentGateway {
1105
1332
  } else if (stepIdFilePath) {
1106
1333
  this.clearStepIdFile(stepIdFilePath);
1107
1334
  }
1335
+ if (laneContextFilePath) {
1336
+ this.updateContextFileStepId(laneContextFilePath, null);
1337
+ }
1108
1338
  this.inFlightDispatches--;
1109
1339
  if (this.inFlightDispatches === 0 && this.drainResolvers.length > 0) {
1110
1340
  const resolvers = this.drainResolvers.splice(0);
@@ -1174,19 +1404,47 @@ export class ParallAgentGateway {
1174
1404
  for (const item of fork.queue.splice(0)) item.resolve(false);
1175
1405
  break;
1176
1406
  }
1177
- const items = fork.queue.splice(0);
1407
+ // Lane-ledger message events batch per lane identity (chat + thread)
1408
+ // so a fork's claim/steer/complete always addresses one lane.
1409
+ let items: ForkQueueItem[];
1410
+ const head = fork.queue[0];
1411
+ if (head && this.usesLaneLedger(head.event)) {
1412
+ const headKey = this.dispatchGroupKey(head.event);
1413
+ const splitAt = fork.queue.findIndex((it) => this.dispatchGroupKey(it.event) !== headKey);
1414
+ items = splitAt === -1 ? fork.queue.splice(0) : fork.queue.splice(0, splitAt);
1415
+ } else {
1416
+ items = fork.queue.splice(0);
1417
+ }
1178
1418
  const events = items.map((item) => item.event);
1179
1419
  const last = events[events.length - 1];
1180
1420
  const earlier = events.slice(0, -1);
1181
1421
  try {
1182
1422
  const batchText: string[] = [];
1183
- const dispatched = await this.runDispatch(
1184
- last,
1185
- fork.fork.sessionKey,
1186
- buildForkScopePrefix(last) + buildEventBody(last),
1187
- earlier,
1188
- batchText,
1189
- );
1423
+ let dispatched: boolean;
1424
+ if (this.usesLaneLedger(last)) {
1425
+ const outcome = await this.dispatchLaneGroup({
1426
+ events,
1427
+ sessionKey: fork.fork.sessionKey,
1428
+ body: buildForkScopePrefix(last) + buildEventBody(last),
1429
+ earlier,
1430
+ captureText: batchText,
1431
+ hasMoreLocal: () => fork.queue.length > 0,
1432
+ });
1433
+ if (outcome === 'foreign') {
1434
+ // Another pod owns the lane — the events stay pending server-side.
1435
+ for (const item of items) item.resolve(false);
1436
+ break;
1437
+ }
1438
+ dispatched = outcome === 'dispatched';
1439
+ } else {
1440
+ dispatched = await this.runDispatch(
1441
+ last,
1442
+ fork.fork.sessionKey,
1443
+ buildForkScopePrefix(last) + buildEventBody(last),
1444
+ earlier,
1445
+ batchText,
1446
+ );
1447
+ }
1190
1448
  if (!dispatched) {
1191
1449
  // Shutdown short-circuit — resolve un-acked so the server requeues
1192
1450
  // for the replacement pod and stop draining further items.
@@ -1306,9 +1564,12 @@ export class ParallAgentGateway {
1306
1564
  break;
1307
1565
  }
1308
1566
 
1309
- const targetId = this.dispatchState.mainBuffer[0].targetId;
1567
+ const groupKey = this.dispatchGroupKey(this.dispatchState.mainBuffer[0]);
1310
1568
  const events: ParallEvent[] = [];
1311
- while (this.dispatchState.mainBuffer[0]?.targetId === targetId) {
1569
+ while (
1570
+ this.dispatchState.mainBuffer[0] &&
1571
+ this.dispatchGroupKey(this.dispatchState.mainBuffer[0]) === groupKey
1572
+ ) {
1312
1573
  events.push(this.dispatchState.mainBuffer.shift()!);
1313
1574
  }
1314
1575
 
@@ -1321,9 +1582,42 @@ export class ParallAgentGateway {
1321
1582
  : this.dispatchState.pendingForkResults.splice(0);
1322
1583
  const forkPrefix = buildForkResultPrefix(pendingFork);
1323
1584
  this.dispatchState.mainCurrentTargetId = event.targetId;
1585
+ this.mainCurrentGroupKey = groupKey;
1324
1586
  this.dispatchState.mainPreDispatchBranchPoint = this.opts.dispatchAdapter.getBranchPoint?.(
1325
1587
  this.opts.runtimeKey,
1326
1588
  );
1589
+ if (this.usesLaneLedger(event)) {
1590
+ let outcome: 'dispatched' | 'foreign' | 'shutdown';
1591
+ try {
1592
+ outcome = await this.dispatchLaneGroup({
1593
+ events,
1594
+ sessionKey: this.opts.runtimeKey,
1595
+ body: forkPrefix + buildEventBody(event),
1596
+ earlier,
1597
+ hasMoreLocal: () =>
1598
+ this.dispatchState.mainBuffer.some((e) => this.dispatchGroupKey(e) === groupKey),
1599
+ });
1600
+ } catch (err) {
1601
+ // The lane was released inside dispatchLaneGroup — members are
1602
+ // pending again server-side; drop them locally and move on.
1603
+ this.opts.log?.error(`lane dispatch failed for ${event.messageId}: ${String(err)}`);
1604
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1605
+ for (const ev of events) this.dispatchedMessages.delete(ev.messageId);
1606
+ continue;
1607
+ }
1608
+ if (outcome === 'shutdown') {
1609
+ this.dispatchState.mainBuffer.unshift(...events);
1610
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1611
+ break;
1612
+ }
1613
+ if (outcome === 'foreign') {
1614
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1615
+ continue;
1616
+ }
1617
+ // Dispatched — resolution happened server-side (reply cover or
1618
+ // no_action sweep); no legacy acks.
1619
+ continue;
1620
+ }
1327
1621
  try {
1328
1622
  await this.emitDispatchReceived(event);
1329
1623
  } catch (err) {
@@ -1365,6 +1659,7 @@ export class ParallAgentGateway {
1365
1659
  this.draining = false;
1366
1660
  this.dispatchState.mainDispatching = false;
1367
1661
  this.dispatchState.mainCurrentTargetId = undefined;
1662
+ this.mainCurrentGroupKey = undefined;
1368
1663
  this.dispatchState.mainPreDispatchBranchPoint = undefined;
1369
1664
  if (!this.shuttingDown && this.dispatchState.mainBuffer.length > 0) {
1370
1665
  // Opportunistic re-drain — best-effort, not a recovery deadline, so it
@@ -1397,18 +1692,50 @@ export class ParallAgentGateway {
1397
1692
  const forkPrefix = buildForkResultPrefix(pendingFork);
1398
1693
  this.dispatchState.mainDispatching = true;
1399
1694
  this.dispatchState.mainCurrentTargetId = event.targetId;
1695
+ this.mainCurrentGroupKey = this.dispatchGroupKey(event);
1400
1696
  // Snapshot the on-disk branch point BEFORE runDispatch starts writing
1401
1697
  // to the session file. Fork sessions created while main is in-flight
1402
1698
  // use this to branch from the clean pre-dispatch state.
1403
1699
  this.dispatchState.mainPreDispatchBranchPoint = this.opts.dispatchAdapter.getBranchPoint?.(
1404
1700
  this.opts.runtimeKey,
1405
1701
  );
1702
+ if (this.usesLaneLedger(event)) {
1703
+ // Ledger flow: claim replaces mark-received; complete/reply replace
1704
+ // acks. A foreign incumbent leaves the event pending for re-drive.
1705
+ let outcome: 'dispatched' | 'foreign' | 'shutdown' = 'shutdown';
1706
+ try {
1707
+ try {
1708
+ outcome = await this.dispatchLaneGroup({
1709
+ events: [event],
1710
+ sessionKey: this.opts.runtimeKey,
1711
+ body: forkPrefix + buildEventBody(event),
1712
+ earlier: [],
1713
+ hasMoreLocal: () =>
1714
+ this.dispatchState.mainBuffer.some(
1715
+ (e) => this.dispatchGroupKey(e) === this.dispatchGroupKey(event),
1716
+ ),
1717
+ });
1718
+ } catch (err) {
1719
+ // Same failure contract as the buffered-group path: accumulated
1720
+ // fork results must survive a failed turn for later replay.
1721
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1722
+ throw err;
1723
+ }
1724
+ if (outcome !== 'dispatched') {
1725
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1726
+ }
1727
+ } finally {
1728
+ await this.drainMainBuffer();
1729
+ }
1730
+ return outcome === 'dispatched';
1731
+ }
1406
1732
  try {
1407
1733
  await this.emitDispatchReceived(event);
1408
1734
  } catch (err) {
1409
1735
  this.opts.log?.warn?.(`mark-received failed, leaving unacked for retry: ${String(err)}`);
1410
1736
  this.dispatchState.mainDispatching = false;
1411
1737
  this.dispatchState.mainCurrentTargetId = undefined;
1738
+ this.mainCurrentGroupKey = undefined;
1412
1739
  this.dispatchState.mainPreDispatchBranchPoint = undefined;
1413
1740
  this.dispatchState.pendingForkResults.unshift(...pendingFork);
1414
1741
  return false;
@@ -1439,7 +1766,26 @@ export class ParallAgentGateway {
1439
1766
  // arrival order is preserved and the event cannot be orphaned in a
1440
1767
  // gap between the steer await and the push.
1441
1768
  this.dispatchState.mainBuffer.push(event);
1442
- if (
1769
+ if (this.usesLaneLedger(event)) {
1770
+ // Ledger flow: fold into the live lane server-side FIRST, then
1771
+ // inject. An un-folded injection is forbidden (the pending WorkItem
1772
+ // would re-drive after complete and be handled twice); a failed
1773
+ // fold leaves the event buffered — the drain claims it as its own
1774
+ // turn. Injection requires an exact lane match (same chat AND same
1775
+ // thread) — a thread message never rides a channel turn.
1776
+ if (
1777
+ this.mainCurrentGroupKey === this.dispatchGroupKey(event) &&
1778
+ (await this.laneLedger?.steerLive(event)) &&
1779
+ (await this.opts.dispatchAdapter.enqueueDuringDispatch?.(
1780
+ this.opts.runtimeKey,
1781
+ buildEventBody(event),
1782
+ ))
1783
+ ) {
1784
+ this.opts.log?.info(
1785
+ `steer folded+injected for ${event.messageId} (will drain for bookkeeping)`,
1786
+ );
1787
+ }
1788
+ } else if (
1443
1789
  this.dispatchState.mainCurrentTargetId === event.targetId &&
1444
1790
  (await this.opts.dispatchAdapter.enqueueDuringDispatch?.(
1445
1791
  this.opts.runtimeKey,
@@ -1649,9 +1995,13 @@ export class ParallAgentGateway {
1649
1995
  try {
1650
1996
  const dispatched = await this.handleInboundEvent(event);
1651
1997
  if (dispatched) {
1652
- this.opts.client
1653
- .ackDispatch(this.opts.config.org_id, { source_type: 'message', source_id: data.id })
1654
- .catch(() => {});
1998
+ // Ledger events resolve server-side (reply cover / no_action sweep);
1999
+ // the legacy by-source ack is only for non-ledger runtimes.
2000
+ if (!this.usesLaneLedger(event)) {
2001
+ this.opts.client
2002
+ .ackDispatch(this.opts.config.org_id, { source_type: 'message', source_id: data.id })
2003
+ .catch(() => {});
2004
+ }
1655
2005
  } else {
1656
2006
  this.dispatchedMessages.delete(data.id);
1657
2007
  }
@@ -1661,7 +2011,30 @@ export class ParallAgentGateway {
1661
2011
  }
1662
2012
  }
1663
2013
 
1664
- private async handleTaskAssignment(task: Task, ackSourceId?: string): Promise<boolean> {
2014
+ // Ledger re-drive consumption: dispatch.new message hints re-enter the
2015
+ // shared WorkItem consumption path (same protocol as catch-up).
2016
+ private async handleMessageRedrive(item: DispatchNewData): Promise<void> {
2017
+ if (!item.chat_id || !item.source_id) return;
2018
+ await this.consumeMessageWorkItem({
2019
+ id: item.id,
2020
+ source_id: item.source_id,
2021
+ chat_id: item.chat_id,
2022
+ });
2023
+ }
2024
+
2025
+ private consumeMessageWorkItem(item: {
2026
+ id: string;
2027
+ source_id: string;
2028
+ chat_id: string;
2029
+ }): Promise<void> {
2030
+ return consumeMessageWorkItem(this.laneFlowHost(), item);
2031
+ }
2032
+
2033
+ private async handleTaskAssignment(
2034
+ task: Task,
2035
+ ackSourceId?: string,
2036
+ dispatchEventId?: string,
2037
+ ): Promise<boolean> {
1665
2038
  if (this.shuttingDown) return false; // drain window — let server requeue via catch-up
1666
2039
  const dedupeKey = `${task.id}:${task.updated_at}`;
1667
2040
  if (this.dispatchedTasks.has(dedupeKey)) {
@@ -1689,6 +2062,7 @@ export class ParallAgentGateway {
1689
2062
  sentAt: task.updated_at ?? task.created_at,
1690
2063
  ackSourceType: 'task_activity',
1691
2064
  ackSourceId,
2065
+ dispatchEventId,
1692
2066
  };
1693
2067
 
1694
2068
  const dispatched = await this.handleInboundEvent(event);
@@ -1701,7 +2075,7 @@ export class ParallAgentGateway {
1701
2075
  private async handleTaskDispatch(
1702
2076
  taskId: string,
1703
2077
  ackSourceId?: string,
1704
- opts: { allowCreator?: boolean } = {},
2078
+ opts: { allowCreator?: boolean; dispatchEventId?: string } = {},
1705
2079
  ): Promise<boolean> {
1706
2080
  let task: Awaited<ReturnType<typeof this.opts.client.getTask>> | null = null;
1707
2081
  try {
@@ -1719,7 +2093,7 @@ export class ParallAgentGateway {
1719
2093
  );
1720
2094
  return true;
1721
2095
  }
1722
- return this.handleTaskAssignment(task, ackSourceId);
2096
+ return this.handleTaskAssignment(task, ackSourceId, opts.dispatchEventId);
1723
2097
  }
1724
2098
 
1725
2099
  private async handleTaskComment(
@@ -1976,6 +2350,98 @@ export class ParallAgentGateway {
1976
2350
  return this.handleExternalTriggerRun(run);
1977
2351
  }
1978
2352
 
2353
+ // fetchAndHandleChannelMessage resolves a channel_message dispatch to its
2354
+ // durable ChannelMessage + conversation and hands it to the inbound
2355
+ // pipeline. targetId = the ChannelConversation id, so per-conversation
2356
+ // multi-turn continuity rides the same per-target session mechanics as
2357
+ // chats. Design: docs/engineering-design/external-im-channel-design.md.
2358
+ private async fetchAndHandleChannelMessage(messageId: string): Promise<boolean> {
2359
+ if (this.shuttingDown) return false;
2360
+ // Capped dedupe (the chat-message path, not the unbounded task set): a busy
2361
+ // external IM conversation would otherwise retain one key per message ever
2362
+ // handled on a long-lived agent. On a RETRYABLE failure the claim is
2363
+ // released so a later dispatch.new / catch-up re-fetches (matching the
2364
+ // chat-message path); a 404/stale result keeps the claim and acks.
2365
+ const claimKey = `channel_message:${messageId}`;
2366
+ if (!this.tryClaimMessage(claimKey)) return false;
2367
+
2368
+ let msg: ChannelMessage | null = null;
2369
+ let conv: ChannelConversation | null = null;
2370
+ try {
2371
+ msg = await this.opts.client.getChannelMessage(this.opts.config.org_id, messageId);
2372
+ conv = await this.opts.client.getChannelConversation(
2373
+ this.opts.config.org_id,
2374
+ msg.conversation_id,
2375
+ );
2376
+ } catch (err: unknown) {
2377
+ const status = (err as { status?: number })?.status;
2378
+ if (status === 404) {
2379
+ this.opts.log?.warn(
2380
+ `channel message ${messageId} not accessible (404), acking stale dispatch`,
2381
+ );
2382
+ return true;
2383
+ }
2384
+ this.dispatchedMessages.delete(claimKey);
2385
+ this.opts.log?.warn(
2386
+ `channel message fetch failed for ${messageId}, leaving pending: ${String(err)}`,
2387
+ );
2388
+ return false;
2389
+ }
2390
+ if (!msg || !conv) {
2391
+ return true;
2392
+ }
2393
+ this.opts.log?.info(`channel message: ${msg.id} (conversation ${conv.id})`);
2394
+
2395
+ // Resolve the provider from the conversation's connection for prompt
2396
+ // labeling + the reply-clip hint. The connection id → provider mapping
2397
+ // is stable, so a tiny cache avoids one fetch per message.
2398
+ let provider = this.channelConnectionProviders.get(conv.connection_id);
2399
+ if (!provider) {
2400
+ try {
2401
+ const connection = await this.opts.client.getChannelConnection(
2402
+ this.opts.config.org_id,
2403
+ conv.connection_id,
2404
+ );
2405
+ provider = connection.provider;
2406
+ this.channelConnectionProviders.set(conv.connection_id, provider);
2407
+ } catch {
2408
+ provider = undefined; // label degrades; reply hint still names the clip generically
2409
+ }
2410
+ }
2411
+
2412
+ const event: ParallEvent = {
2413
+ type: 'channel_message',
2414
+ targetId: conv.id,
2415
+ targetName: conv.external_user_name || conv.external_conversation_id,
2416
+ targetType: 'channel_conversation',
2417
+ senderId: msg.external_user_id || 'external',
2418
+ senderName: msg.external_user_name || msg.external_user_id || 'external user',
2419
+ messageId: msg.id,
2420
+ body: msg.text,
2421
+ sentAt: msg.received_at,
2422
+ channelProvider: provider,
2423
+ channelConversationType: conv.conversation_type || undefined,
2424
+ channelExternalConversationId: conv.external_conversation_id,
2425
+ channelExternalMessageId: msg.external_message_id,
2426
+ ackSourceType: 'channel_message',
2427
+ ackSourceId: msg.id,
2428
+ };
2429
+
2430
+ // Release the claim if the event isn't actually dispatched (or throws) so
2431
+ // a retry can re-attempt — same contract as the chat-message path.
2432
+ let dispatched: boolean;
2433
+ try {
2434
+ dispatched = await this.handleInboundEvent(event);
2435
+ } catch (err) {
2436
+ this.dispatchedMessages.delete(claimKey);
2437
+ throw err;
2438
+ }
2439
+ if (!dispatched) {
2440
+ this.dispatchedMessages.delete(claimKey);
2441
+ }
2442
+ return dispatched;
2443
+ }
2444
+
1979
2445
  private async handleExternalTriggerRun(run: ExternalTriggerRun): Promise<boolean> {
1980
2446
  if (this.shuttingDown) return false;
1981
2447
  const dedupeKey = `external_trigger_run:${run.id}`;
@@ -2129,12 +2595,22 @@ export class ParallAgentGateway {
2129
2595
 
2130
2596
  processed++;
2131
2597
  try {
2132
- let dispatched = false;
2598
+ const ackItem = () => {
2599
+ this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
2600
+ };
2133
2601
  if (item.event_type === 'task_assign' && item.task_id) {
2134
2602
  try {
2135
- dispatched = await this.handleTaskDispatch(
2136
- item.task_id,
2137
- item.source_id ?? item.task_id,
2603
+ await this.consumeTypedDispatch(
2604
+ { dispatchEventId: item.id },
2605
+ (dispatchEventId) =>
2606
+ this.handleTaskDispatch(
2607
+ item.task_id ?? '',
2608
+ item.source_id ?? item.task_id ?? '',
2609
+ {
2610
+ dispatchEventId,
2611
+ },
2612
+ ),
2613
+ ackItem,
2138
2614
  );
2139
2615
  } catch (err: unknown) {
2140
2616
  this.opts.log?.warn(
@@ -2144,10 +2620,18 @@ export class ParallAgentGateway {
2144
2620
  }
2145
2621
  } else if (item.event_type === 'task_update' && item.task_id) {
2146
2622
  try {
2147
- dispatched = await this.handleTaskDispatch(
2148
- item.task_id,
2149
- item.source_id ?? item.task_id,
2150
- { allowCreator: true },
2623
+ await this.consumeTypedDispatch(
2624
+ { dispatchEventId: item.id },
2625
+ (dispatchEventId) =>
2626
+ this.handleTaskDispatch(
2627
+ item.task_id ?? '',
2628
+ item.source_id ?? item.task_id ?? '',
2629
+ {
2630
+ allowCreator: true,
2631
+ dispatchEventId,
2632
+ },
2633
+ ),
2634
+ ackItem,
2151
2635
  );
2152
2636
  } catch (err: unknown) {
2153
2637
  this.opts.log?.warn(
@@ -2156,70 +2640,58 @@ export class ParallAgentGateway {
2156
2640
  continue;
2157
2641
  }
2158
2642
  } else if (item.event_type === 'task_comment' && item.source_id && item.task_id) {
2159
- dispatched = await this.handleTaskComment(
2160
- item.source_id,
2161
- item.task_id,
2162
- item.actor_id,
2163
- item.delivery_reason,
2643
+ await this.consumeTypedDispatch(
2644
+ { dispatchEventId: item.id },
2645
+ () =>
2646
+ this.handleTaskComment(
2647
+ item.source_id,
2648
+ item.task_id ?? '',
2649
+ item.actor_id,
2650
+ item.delivery_reason,
2651
+ ),
2652
+ ackItem,
2164
2653
  );
2165
2654
  } else if (item.event_type === 'wiki_comment' && item.source_id) {
2166
- dispatched = await this.handleWikiComment(
2167
- item.source_id,
2168
- item.actor_id,
2169
- item.delivery_reason,
2655
+ await this.consumeTypedDispatch(
2656
+ { dispatchEventId: item.id },
2657
+ () => this.handleWikiComment(item.source_id, item.actor_id, item.delivery_reason),
2658
+ ackItem,
2170
2659
  );
2171
2660
  } else if (item.event_type === 'schedule.fire' && item.source_id) {
2172
- dispatched = await this.fetchAndHandleScheduleFire(item.source_id, item.actor_id);
2661
+ await this.consumeTypedDispatch(
2662
+ { dispatchEventId: item.id },
2663
+ () => this.fetchAndHandleScheduleFire(item.source_id, item.actor_id),
2664
+ ackItem,
2665
+ );
2173
2666
  } else if (item.event_type === 'external_trigger' && item.source_id) {
2174
- dispatched = await this.fetchAndHandleExternalTriggerRun(item.source_id);
2667
+ await this.consumeTypedDispatch(
2668
+ { dispatchEventId: item.id },
2669
+ () => this.fetchAndHandleExternalTriggerRun(item.source_id),
2670
+ ackItem,
2671
+ );
2672
+ } else if (item.event_type === 'channel_message' && item.source_id) {
2673
+ await this.consumeTypedDispatch(
2674
+ { dispatchEventId: item.id },
2675
+ () => this.fetchAndHandleChannelMessage(item.source_id),
2676
+ ackItem,
2677
+ );
2175
2678
  } else if (item.event_type === 'approval_decided' && item.source_id) {
2176
- dispatched = await this.fetchAndHandleApprovalDecided(
2177
- item.source_id,
2178
- item.actor_id,
2179
- item.chat_id ?? null,
2679
+ await this.consumeTypedDispatch(
2680
+ { dispatchEventId: item.id },
2681
+ () =>
2682
+ this.fetchAndHandleApprovalDecided(
2683
+ item.source_id,
2684
+ item.actor_id,
2685
+ item.chat_id ?? null,
2686
+ ),
2687
+ ackItem,
2180
2688
  );
2181
2689
  } else if (item.event_type === 'message' && item.source_id && item.chat_id) {
2182
- if (!this.tryClaimMessage(item.source_id)) continue;
2183
- let msg: Awaited<ReturnType<typeof this.opts.client.getMessage>> | null = null;
2184
- let msgFetchFailed = false;
2185
- try {
2186
- msg = await this.opts.client.getMessage(item.source_id);
2187
- } catch (err: unknown) {
2188
- const status = (err as { status?: number })?.status;
2189
- if (status === 404) {
2190
- msg = null;
2191
- } else {
2192
- msgFetchFailed = true;
2193
- this.opts.log?.warn(
2194
- `catch-up message fetch failed for ${item.source_id}, leaving pending: ${String(err)}`,
2195
- );
2196
- }
2197
- }
2198
- if (msgFetchFailed) {
2199
- this.dispatchedMessages.delete(item.source_id);
2200
- continue;
2201
- }
2202
- if (!msg || msg.sender_id === this.opts.agentUserId) {
2203
- this.dispatchedMessages.delete(item.source_id);
2204
- this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
2205
- continue;
2206
- }
2207
-
2208
- const decision = await this.buildMessageDispatchDecision(item.chat_id, msg);
2209
- if (decision.action === 'retry') {
2210
- this.dispatchedMessages.delete(item.source_id);
2211
- continue;
2212
- }
2213
- if (decision.action === 'skip') {
2214
- this.dispatchedMessages.delete(item.source_id);
2215
- this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
2216
- continue;
2217
- }
2218
-
2219
- dispatched = await this.handleInboundEvent(decision.event);
2220
- }
2221
- if (dispatched) {
2222
- this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
2690
+ await this.consumeMessageWorkItem({
2691
+ id: item.id,
2692
+ source_id: item.source_id,
2693
+ chat_id: item.chat_id,
2694
+ });
2223
2695
  }
2224
2696
  } catch (err) {
2225
2697
  this.opts.log?.warn(
@@ -2307,6 +2779,20 @@ export class ParallAgentGateway {
2307
2779
  this.abortFork(targetId, 'ws reconnect');
2308
2780
  }
2309
2781
  }
2782
+ // Reconnect with nothing in flight: interrupted turns can't resume, so
2783
+ // hand their lane members back to the pending pool before catch-up
2784
+ // re-claims (an in-flight turn keeps its lanes — it is still the owner).
2785
+ if (this.laneLedger && this.inFlightDispatches === 0 && this.laneLedger.activeCount > 0) {
2786
+ log?.info(`releasing ${this.laneLedger.activeCount} stale lane(s) on reconnect`);
2787
+ await this.laneLedger.releaseAll();
2788
+ }
2789
+ // Re-probe the ledger each connection: a sticky downgrade from a
2790
+ // transient edge 404 during a rolling deploy must not outlive the
2791
+ // connection that observed it.
2792
+ if (this.laneLedger && this.ledgerDisabled) {
2793
+ log?.info('re-probing dispatch ledger after reconnect (was disabled)');
2794
+ this.ledgerDisabled = false;
2795
+ }
2310
2796
  const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
2311
2797
  try {
2312
2798
  const count = await fetchAllChats(client, config.org_id, this.chatInfoMap);
@@ -2391,6 +2877,14 @@ export class ParallAgentGateway {
2391
2877
 
2392
2878
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
2393
2879
 
2880
+ // Completed turns already released their lanes; whatever is left belongs
2881
+ // to interrupted work — hand the members back so the replacement pod
2882
+ // re-claims immediately instead of waiting out the lease.
2883
+ if (this.laneLedger && this.laneLedger.activeCount > 0) {
2884
+ this.opts.log?.info(`releasing ${this.laneLedger.activeCount} lane(s) on shutdown`);
2885
+ await this.laneLedger.releaseAll();
2886
+ }
2887
+
2394
2888
  await this.opts.onBeforeDisconnect?.();
2395
2889
 
2396
2890
  this.opts.ws.disconnect();