@parall/agent-core 1.37.0 → 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.
@@ -36,6 +36,13 @@ import type {
36
36
  GatewayLogger,
37
37
  RuntimeEvent,
38
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';
39
46
  import { routeTrigger } from './routing.js';
40
47
  import {
41
48
  clearDispatchMessageId,
@@ -89,7 +96,7 @@ type ActiveForkState = {
89
96
  deadlineExceeded: boolean;
90
97
  };
91
98
 
92
- type DispatchableMessage = {
99
+ export type DispatchableMessage = {
93
100
  id: string;
94
101
  sender_id: string;
95
102
  sender?: { display_name?: string | null };
@@ -101,7 +108,7 @@ type DispatchableMessage = {
101
108
  created_at?: string;
102
109
  };
103
110
 
104
- type MessageDispatchDecision =
111
+ export type MessageDispatchDecision =
105
112
  | { action: 'dispatch'; event: ParallEvent }
106
113
  | { action: 'skip' }
107
114
  | { action: 'retry' };
@@ -133,6 +140,13 @@ export type ParallGatewayOptions = {
133
140
  contextFilePathForSession?: (sessionKey: string) => string | undefined;
134
141
  /** @deprecated Use contextFilePathForSession. Kept for runtimes that haven't migrated. */
135
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;
136
150
  onConfigUpdate?: (data: AgentConfigUpdateData) => Promise<void> | void;
137
151
  onSessionReady?: (state: {
138
152
  activeSessionId?: string;
@@ -303,6 +317,15 @@ export class ParallAgentGateway {
303
317
  private drainResolvers: Array<() => void> = [];
304
318
  private pendingRestartNotification: string | null = null;
305
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
+
306
329
  private readonly DISPATCHED_MESSAGES_CAP = 5000;
307
330
  // SHUTDOWN_DEADLINE_MS is read by waitForDrain via the configured value
308
331
  // below — kept as instance state so per-runtime configs can override it
@@ -312,6 +335,14 @@ export class ParallAgentGateway {
312
335
  private readonly DISPATCH_DEADLINE_MS: number;
313
336
 
314
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
+ }
315
346
  this.SHUTDOWN_DEADLINE_MS = opts.shutdownDeadlineMs ?? 60_000;
316
347
  this.FORK_DEADLINE_MS = opts.forkDeadlineMs ?? 2 * 60 * 60_000;
317
348
  this.DISPATCH_DEADLINE_MS = opts.dispatchDeadlineMs ?? 20 * 60_000;
@@ -395,15 +426,30 @@ export class ParallAgentGateway {
395
426
  if (data.assignee_id !== this.opts.agentUserId) return;
396
427
  if (data.status !== 'todo' && data.status !== 'in_progress') return;
397
428
  try {
398
- const dispatched = await this.handleTaskAssignment(data, data.id);
399
- if (dispatched) {
400
- this.opts.client
401
- .ackDispatch(this.opts.config.org_id, {
402
- source_type: 'task_activity',
403
- source_id: data.id,
404
- })
405
- .catch(() => {});
406
- }
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
+ );
407
453
  } catch (err) {
408
454
  this.opts.log?.error(`task dispatch failed for ${data.id}: ${String(err)}`);
409
455
  }
@@ -413,15 +459,19 @@ export class ParallAgentGateway {
413
459
  if (data.event_type === 'task_comment') {
414
460
  if (!data.source_id || !data.task_id) return;
415
461
  try {
416
- const dispatched = await this.handleTaskComment(
417
- data.source_id,
418
- data.task_id,
419
- data.actor_id,
420
- 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
+ },
421
474
  );
422
- if (dispatched) {
423
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
424
- }
425
475
  } catch (err) {
426
476
  this.opts.log?.error(
427
477
  `task comment dispatch failed for ${data.source_id}: ${String(err)}`,
@@ -430,14 +480,13 @@ export class ParallAgentGateway {
430
480
  } else if (data.event_type === 'wiki_comment') {
431
481
  if (!data.source_id) return;
432
482
  try {
433
- const dispatched = await this.handleWikiComment(
434
- data.source_id,
435
- data.actor_id,
436
- 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
+ },
437
489
  );
438
- if (dispatched) {
439
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
440
- }
441
490
  } catch (err) {
442
491
  this.opts.log?.error(
443
492
  `wiki comment dispatch failed for ${data.source_id}: ${String(err)}`,
@@ -446,24 +495,30 @@ export class ParallAgentGateway {
446
495
  } else if (data.event_type === 'task_update') {
447
496
  if (!data.task_id) return;
448
497
  try {
449
- const dispatched = await this.handleTaskDispatch(
450
- data.task_id,
451
- data.source_id ?? data.task_id,
452
- { 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
+ },
453
508
  );
454
- if (dispatched) {
455
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
456
- }
457
509
  } catch (err) {
458
510
  this.opts.log?.error(`task update dispatch failed for ${data.task_id}: ${String(err)}`);
459
511
  }
460
512
  } else if (data.event_type === 'schedule.fire') {
461
513
  if (!data.source_id) return;
462
514
  try {
463
- const dispatched = await this.fetchAndHandleScheduleFire(data.source_id, data.actor_id);
464
- if (dispatched) {
465
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
466
- }
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
+ );
467
522
  } catch (err) {
468
523
  this.opts.log?.error(
469
524
  `schedule fire dispatch failed for ${data.source_id}: ${String(err)}`,
@@ -472,10 +527,13 @@ export class ParallAgentGateway {
472
527
  } else if (data.event_type === 'external_trigger') {
473
528
  if (!data.source_id) return;
474
529
  try {
475
- const dispatched = await this.fetchAndHandleExternalTriggerRun(data.source_id);
476
- if (dispatched) {
477
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
478
- }
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
+ );
479
537
  } catch (err) {
480
538
  this.opts.log?.error(
481
539
  `external trigger dispatch failed for ${data.source_id}: ${String(err)}`,
@@ -484,10 +542,13 @@ export class ParallAgentGateway {
484
542
  } else if (data.event_type === 'channel_message') {
485
543
  if (!data.source_id) return;
486
544
  try {
487
- const dispatched = await this.fetchAndHandleChannelMessage(data.source_id);
488
- if (dispatched) {
489
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
490
- }
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
+ );
491
552
  } catch (err) {
492
553
  this.opts.log?.error(
493
554
  `channel message dispatch failed for ${data.source_id}: ${String(err)}`,
@@ -496,19 +557,37 @@ export class ParallAgentGateway {
496
557
  } else if (data.event_type === 'approval_decided') {
497
558
  if (!data.source_id) return;
498
559
  try {
499
- const dispatched = await this.fetchAndHandleApprovalDecided(
500
- data.source_id,
501
- data.actor_id,
502
- 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
+ },
503
571
  );
504
- if (dispatched) {
505
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
506
- }
507
572
  } catch (err) {
508
573
  this.opts.log?.error(
509
574
  `approval decided dispatch failed for ${data.source_id}: ${String(err)}`,
510
575
  );
511
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
+ }
512
591
  } else if (data.event_type !== 'message' && data.event_type !== 'task_assign') {
513
592
  // Truly unknown event_type — log so a newly-added dispatch type
514
593
  // not yet wired here surfaces during runtime testing. "message"
@@ -556,6 +635,61 @@ export class ParallAgentGateway {
556
635
  });
557
636
  }
558
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
+
559
693
  private buildDispatchContext(event: ParallEvent, sessionKey: string): DispatchContext {
560
694
  const binding = this.sessionBindings.get(sessionKey);
561
695
  return {
@@ -572,6 +706,7 @@ export class ParallAgentGateway {
572
706
  noReply: event.noReply ?? false,
573
707
  contextFilePath: this.opts.contextFilePathForSession?.(sessionKey),
574
708
  stepIdFilePath: this.opts.stepIdFilePathForSession?.(sessionKey),
709
+ contextDirPath: this.opts.dispatchContextDir,
575
710
  client: this.opts.client,
576
711
  log: this.opts.log,
577
712
  };
@@ -658,6 +793,7 @@ export class ParallAgentGateway {
658
793
  runtimeEvent: RuntimeEvent,
659
794
  stepIdFilePath?: string,
660
795
  contextFilePath?: string,
796
+ laneContextFilePath?: string,
661
797
  ) {
662
798
  const target = resolveStepTarget(event);
663
799
  try {
@@ -721,6 +857,9 @@ export class ParallAgentGateway {
721
857
  } else if (stepIdFilePath) {
722
858
  this.writeStepIdFile(stepIdFilePath, step.id);
723
859
  }
860
+ if (laneContextFilePath) {
861
+ this.updateContextFileStepId(laneContextFilePath, step.id);
862
+ }
724
863
  break;
725
864
  }
726
865
 
@@ -749,6 +888,9 @@ export class ParallAgentGateway {
749
888
  } else if (stepIdFilePath) {
750
889
  this.clearStepIdFile(stepIdFilePath);
751
890
  }
891
+ if (laneContextFilePath) {
892
+ this.updateContextFileStepId(laneContextFilePath, null);
893
+ }
752
894
  break;
753
895
 
754
896
  case 'error':
@@ -832,6 +974,7 @@ export class ParallAgentGateway {
832
974
  sessionKey: string,
833
975
  runtimeEvent: Extract<RuntimeEvent, { type: 'runtime_session' }>,
834
976
  contextFilePath?: string,
977
+ laneContextFilePath?: string,
835
978
  ): Promise<AgentSessionBinding> {
836
979
  const runtimeLaneKey = runtimeEvent.runtimeLaneKey || sessionKey;
837
980
  const existing = this.sessionBindings.get(sessionKey);
@@ -893,6 +1036,9 @@ export class ParallAgentGateway {
893
1036
  if (contextFilePath) {
894
1037
  this.updateContextFileSessionId(contextFilePath, session.id);
895
1038
  }
1039
+ if (laneContextFilePath) {
1040
+ this.updateContextFileSessionId(laneContextFilePath, session.id);
1041
+ }
896
1042
  await this.opts.onSessionBinding?.(binding);
897
1043
  return binding;
898
1044
  }
@@ -936,14 +1082,33 @@ export class ParallAgentGateway {
936
1082
  const contextFilePath = dispatchContext.contextFilePath;
937
1083
  const stepIdFilePath = dispatchContext.stepIdFilePath;
938
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
+ };
939
1107
  if (contextFilePath) {
940
- this.writeContextFile(contextFilePath, {
941
- session_id: dispatchContext.sessionId ?? null,
942
- chat_id: dispatchContext.chatId ?? null,
943
- trigger_message_id: dispatchContext.triggerMessageId ?? null,
944
- no_reply: dispatchContext.noReply,
945
- step_id: null,
946
- });
1108
+ this.writeContextFile(contextFilePath, contextBody);
1109
+ }
1110
+ if (laneContextFilePath) {
1111
+ this.writeContextFile(laneContextFilePath, contextBody);
947
1112
  }
948
1113
 
949
1114
  // sync: no await between the shuttingDown check above and this increment
@@ -981,7 +1146,12 @@ export class ParallAgentGateway {
981
1146
  })) {
982
1147
  if (runtimeEvent.type === 'runtime_session') {
983
1148
  const priorAgentSessionId = binding?.agentSessionId;
984
- binding = await this.bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath);
1149
+ binding = await this.bindRuntimeSession(
1150
+ sessionKey,
1151
+ runtimeEvent,
1152
+ contextFilePath,
1153
+ laneContextFilePath,
1154
+ );
985
1155
  if (
986
1156
  event.targetType === 'channel_conversation' &&
987
1157
  binding.agentSessionId !== priorAgentSessionId
@@ -1037,6 +1207,12 @@ export class ParallAgentGateway {
1037
1207
  await this.createInputStep(binding.agentSessionId, event);
1038
1208
  inputStepsCreated = true;
1039
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
+ }
1040
1216
  if (captureText && runtimeEvent.type === 'text' && runtimeEvent.text) {
1041
1217
  captureText.push(runtimeEvent.text);
1042
1218
  }
@@ -1062,6 +1238,7 @@ export class ParallAgentGateway {
1062
1238
  runtimeEvent,
1063
1239
  stepIdFilePath,
1064
1240
  contextFilePath,
1241
+ laneContextFilePath,
1065
1242
  );
1066
1243
  }
1067
1244
  if (!binding) {
@@ -1090,6 +1267,7 @@ export class ParallAgentGateway {
1090
1267
  },
1091
1268
  stepIdFilePath,
1092
1269
  contextFilePath,
1270
+ laneContextFilePath,
1093
1271
  );
1094
1272
  } catch (stepErr) {
1095
1273
  if (this.isSessionNotLiveError(stepErr)) staleDetected = true;
@@ -1154,6 +1332,9 @@ export class ParallAgentGateway {
1154
1332
  } else if (stepIdFilePath) {
1155
1333
  this.clearStepIdFile(stepIdFilePath);
1156
1334
  }
1335
+ if (laneContextFilePath) {
1336
+ this.updateContextFileStepId(laneContextFilePath, null);
1337
+ }
1157
1338
  this.inFlightDispatches--;
1158
1339
  if (this.inFlightDispatches === 0 && this.drainResolvers.length > 0) {
1159
1340
  const resolvers = this.drainResolvers.splice(0);
@@ -1223,19 +1404,47 @@ export class ParallAgentGateway {
1223
1404
  for (const item of fork.queue.splice(0)) item.resolve(false);
1224
1405
  break;
1225
1406
  }
1226
- 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
+ }
1227
1418
  const events = items.map((item) => item.event);
1228
1419
  const last = events[events.length - 1];
1229
1420
  const earlier = events.slice(0, -1);
1230
1421
  try {
1231
1422
  const batchText: string[] = [];
1232
- const dispatched = await this.runDispatch(
1233
- last,
1234
- fork.fork.sessionKey,
1235
- buildForkScopePrefix(last) + buildEventBody(last),
1236
- earlier,
1237
- batchText,
1238
- );
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
+ }
1239
1448
  if (!dispatched) {
1240
1449
  // Shutdown short-circuit — resolve un-acked so the server requeues
1241
1450
  // for the replacement pod and stop draining further items.
@@ -1355,9 +1564,12 @@ export class ParallAgentGateway {
1355
1564
  break;
1356
1565
  }
1357
1566
 
1358
- const targetId = this.dispatchState.mainBuffer[0].targetId;
1567
+ const groupKey = this.dispatchGroupKey(this.dispatchState.mainBuffer[0]);
1359
1568
  const events: ParallEvent[] = [];
1360
- while (this.dispatchState.mainBuffer[0]?.targetId === targetId) {
1569
+ while (
1570
+ this.dispatchState.mainBuffer[0] &&
1571
+ this.dispatchGroupKey(this.dispatchState.mainBuffer[0]) === groupKey
1572
+ ) {
1361
1573
  events.push(this.dispatchState.mainBuffer.shift()!);
1362
1574
  }
1363
1575
 
@@ -1370,9 +1582,42 @@ export class ParallAgentGateway {
1370
1582
  : this.dispatchState.pendingForkResults.splice(0);
1371
1583
  const forkPrefix = buildForkResultPrefix(pendingFork);
1372
1584
  this.dispatchState.mainCurrentTargetId = event.targetId;
1585
+ this.mainCurrentGroupKey = groupKey;
1373
1586
  this.dispatchState.mainPreDispatchBranchPoint = this.opts.dispatchAdapter.getBranchPoint?.(
1374
1587
  this.opts.runtimeKey,
1375
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
+ }
1376
1621
  try {
1377
1622
  await this.emitDispatchReceived(event);
1378
1623
  } catch (err) {
@@ -1414,6 +1659,7 @@ export class ParallAgentGateway {
1414
1659
  this.draining = false;
1415
1660
  this.dispatchState.mainDispatching = false;
1416
1661
  this.dispatchState.mainCurrentTargetId = undefined;
1662
+ this.mainCurrentGroupKey = undefined;
1417
1663
  this.dispatchState.mainPreDispatchBranchPoint = undefined;
1418
1664
  if (!this.shuttingDown && this.dispatchState.mainBuffer.length > 0) {
1419
1665
  // Opportunistic re-drain — best-effort, not a recovery deadline, so it
@@ -1446,18 +1692,50 @@ export class ParallAgentGateway {
1446
1692
  const forkPrefix = buildForkResultPrefix(pendingFork);
1447
1693
  this.dispatchState.mainDispatching = true;
1448
1694
  this.dispatchState.mainCurrentTargetId = event.targetId;
1695
+ this.mainCurrentGroupKey = this.dispatchGroupKey(event);
1449
1696
  // Snapshot the on-disk branch point BEFORE runDispatch starts writing
1450
1697
  // to the session file. Fork sessions created while main is in-flight
1451
1698
  // use this to branch from the clean pre-dispatch state.
1452
1699
  this.dispatchState.mainPreDispatchBranchPoint = this.opts.dispatchAdapter.getBranchPoint?.(
1453
1700
  this.opts.runtimeKey,
1454
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
+ }
1455
1732
  try {
1456
1733
  await this.emitDispatchReceived(event);
1457
1734
  } catch (err) {
1458
1735
  this.opts.log?.warn?.(`mark-received failed, leaving unacked for retry: ${String(err)}`);
1459
1736
  this.dispatchState.mainDispatching = false;
1460
1737
  this.dispatchState.mainCurrentTargetId = undefined;
1738
+ this.mainCurrentGroupKey = undefined;
1461
1739
  this.dispatchState.mainPreDispatchBranchPoint = undefined;
1462
1740
  this.dispatchState.pendingForkResults.unshift(...pendingFork);
1463
1741
  return false;
@@ -1488,7 +1766,26 @@ export class ParallAgentGateway {
1488
1766
  // arrival order is preserved and the event cannot be orphaned in a
1489
1767
  // gap between the steer await and the push.
1490
1768
  this.dispatchState.mainBuffer.push(event);
1491
- 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 (
1492
1789
  this.dispatchState.mainCurrentTargetId === event.targetId &&
1493
1790
  (await this.opts.dispatchAdapter.enqueueDuringDispatch?.(
1494
1791
  this.opts.runtimeKey,
@@ -1698,9 +1995,13 @@ export class ParallAgentGateway {
1698
1995
  try {
1699
1996
  const dispatched = await this.handleInboundEvent(event);
1700
1997
  if (dispatched) {
1701
- this.opts.client
1702
- .ackDispatch(this.opts.config.org_id, { source_type: 'message', source_id: data.id })
1703
- .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
+ }
1704
2005
  } else {
1705
2006
  this.dispatchedMessages.delete(data.id);
1706
2007
  }
@@ -1710,7 +2011,30 @@ export class ParallAgentGateway {
1710
2011
  }
1711
2012
  }
1712
2013
 
1713
- 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> {
1714
2038
  if (this.shuttingDown) return false; // drain window — let server requeue via catch-up
1715
2039
  const dedupeKey = `${task.id}:${task.updated_at}`;
1716
2040
  if (this.dispatchedTasks.has(dedupeKey)) {
@@ -1738,6 +2062,7 @@ export class ParallAgentGateway {
1738
2062
  sentAt: task.updated_at ?? task.created_at,
1739
2063
  ackSourceType: 'task_activity',
1740
2064
  ackSourceId,
2065
+ dispatchEventId,
1741
2066
  };
1742
2067
 
1743
2068
  const dispatched = await this.handleInboundEvent(event);
@@ -1750,7 +2075,7 @@ export class ParallAgentGateway {
1750
2075
  private async handleTaskDispatch(
1751
2076
  taskId: string,
1752
2077
  ackSourceId?: string,
1753
- opts: { allowCreator?: boolean } = {},
2078
+ opts: { allowCreator?: boolean; dispatchEventId?: string } = {},
1754
2079
  ): Promise<boolean> {
1755
2080
  let task: Awaited<ReturnType<typeof this.opts.client.getTask>> | null = null;
1756
2081
  try {
@@ -1768,7 +2093,7 @@ export class ParallAgentGateway {
1768
2093
  );
1769
2094
  return true;
1770
2095
  }
1771
- return this.handleTaskAssignment(task, ackSourceId);
2096
+ return this.handleTaskAssignment(task, ackSourceId, opts.dispatchEventId);
1772
2097
  }
1773
2098
 
1774
2099
  private async handleTaskComment(
@@ -2270,12 +2595,22 @@ export class ParallAgentGateway {
2270
2595
 
2271
2596
  processed++;
2272
2597
  try {
2273
- let dispatched = false;
2598
+ const ackItem = () => {
2599
+ this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
2600
+ };
2274
2601
  if (item.event_type === 'task_assign' && item.task_id) {
2275
2602
  try {
2276
- dispatched = await this.handleTaskDispatch(
2277
- item.task_id,
2278
- 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,
2279
2614
  );
2280
2615
  } catch (err: unknown) {
2281
2616
  this.opts.log?.warn(
@@ -2285,10 +2620,18 @@ export class ParallAgentGateway {
2285
2620
  }
2286
2621
  } else if (item.event_type === 'task_update' && item.task_id) {
2287
2622
  try {
2288
- dispatched = await this.handleTaskDispatch(
2289
- item.task_id,
2290
- item.source_id ?? item.task_id,
2291
- { 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,
2292
2635
  );
2293
2636
  } catch (err: unknown) {
2294
2637
  this.opts.log?.warn(
@@ -2297,72 +2640,58 @@ export class ParallAgentGateway {
2297
2640
  continue;
2298
2641
  }
2299
2642
  } else if (item.event_type === 'task_comment' && item.source_id && item.task_id) {
2300
- dispatched = await this.handleTaskComment(
2301
- item.source_id,
2302
- item.task_id,
2303
- item.actor_id,
2304
- 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,
2305
2653
  );
2306
2654
  } else if (item.event_type === 'wiki_comment' && item.source_id) {
2307
- dispatched = await this.handleWikiComment(
2308
- item.source_id,
2309
- item.actor_id,
2310
- 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,
2311
2659
  );
2312
2660
  } else if (item.event_type === 'schedule.fire' && item.source_id) {
2313
- 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
+ );
2314
2666
  } else if (item.event_type === 'external_trigger' && item.source_id) {
2315
- dispatched = await this.fetchAndHandleExternalTriggerRun(item.source_id);
2667
+ await this.consumeTypedDispatch(
2668
+ { dispatchEventId: item.id },
2669
+ () => this.fetchAndHandleExternalTriggerRun(item.source_id),
2670
+ ackItem,
2671
+ );
2316
2672
  } else if (item.event_type === 'channel_message' && item.source_id) {
2317
- dispatched = await this.fetchAndHandleChannelMessage(item.source_id);
2673
+ await this.consumeTypedDispatch(
2674
+ { dispatchEventId: item.id },
2675
+ () => this.fetchAndHandleChannelMessage(item.source_id),
2676
+ ackItem,
2677
+ );
2318
2678
  } else if (item.event_type === 'approval_decided' && item.source_id) {
2319
- dispatched = await this.fetchAndHandleApprovalDecided(
2320
- item.source_id,
2321
- item.actor_id,
2322
- 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,
2323
2688
  );
2324
2689
  } else if (item.event_type === 'message' && item.source_id && item.chat_id) {
2325
- if (!this.tryClaimMessage(item.source_id)) continue;
2326
- let msg: Awaited<ReturnType<typeof this.opts.client.getMessage>> | null = null;
2327
- let msgFetchFailed = false;
2328
- try {
2329
- msg = await this.opts.client.getMessage(item.source_id);
2330
- } catch (err: unknown) {
2331
- const status = (err as { status?: number })?.status;
2332
- if (status === 404) {
2333
- msg = null;
2334
- } else {
2335
- msgFetchFailed = true;
2336
- this.opts.log?.warn(
2337
- `catch-up message fetch failed for ${item.source_id}, leaving pending: ${String(err)}`,
2338
- );
2339
- }
2340
- }
2341
- if (msgFetchFailed) {
2342
- this.dispatchedMessages.delete(item.source_id);
2343
- continue;
2344
- }
2345
- if (!msg || msg.sender_id === this.opts.agentUserId) {
2346
- this.dispatchedMessages.delete(item.source_id);
2347
- this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
2348
- continue;
2349
- }
2350
-
2351
- const decision = await this.buildMessageDispatchDecision(item.chat_id, msg);
2352
- if (decision.action === 'retry') {
2353
- this.dispatchedMessages.delete(item.source_id);
2354
- continue;
2355
- }
2356
- if (decision.action === 'skip') {
2357
- this.dispatchedMessages.delete(item.source_id);
2358
- this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
2359
- continue;
2360
- }
2361
-
2362
- dispatched = await this.handleInboundEvent(decision.event);
2363
- }
2364
- if (dispatched) {
2365
- 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
+ });
2366
2695
  }
2367
2696
  } catch (err) {
2368
2697
  this.opts.log?.warn(
@@ -2450,6 +2779,20 @@ export class ParallAgentGateway {
2450
2779
  this.abortFork(targetId, 'ws reconnect');
2451
2780
  }
2452
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
+ }
2453
2796
  const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
2454
2797
  try {
2455
2798
  const count = await fetchAllChats(client, config.org_id, this.chatInfoMap);
@@ -2534,6 +2877,14 @@ export class ParallAgentGateway {
2534
2877
 
2535
2878
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
2536
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
+
2537
2888
  await this.opts.onBeforeDisconnect?.();
2538
2889
 
2539
2890
  this.opts.ws.disconnect();