@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.
@@ -3,6 +3,8 @@ import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import { ApiError, MENTION_ALL_USER_ID } from '@parall/sdk';
5
5
  import { buildEventBody, buildEventBodyForForkResult, buildForkResultPrefix, buildForkScopePrefix, } from './event-format.js';
6
+ import { consumeMessageWorkItem, consumeTypedDispatch, dispatchLaneGroup, } from './gateway-lane-flow.js';
7
+ import { LaneLedger } from './lane-ledger.js';
6
8
  import { routeTrigger } from './routing.js';
7
9
  import { clearDispatchMessageId, clearDispatchMetrics, clearDispatchNoReply, clearSessionMessageId, getDispatchMetrics, recordDeliverText, recordMessageSend, recordNoReply, recordToolCall, resetDispatchMetrics, setDispatchMessageId, setDispatchNoReply, setSessionChatId, setSessionMessageId, } from './session-state.js';
8
10
  import { isParallSendCommand, isParallNoReplyCommand, extractShellCommand, } from './bridge-workspace.js';
@@ -143,6 +145,14 @@ export class ParallAgentGateway {
143
145
  inFlightDispatches = 0;
144
146
  drainResolvers = [];
145
147
  pendingRestartNotification = null;
148
+ laneLedger;
149
+ // Sticky fallback: flipped when the server predates the ledger (claim
150
+ // endpoint 404) so every subsequent dispatch uses the legacy flow.
151
+ ledgerDisabled = false;
152
+ // Group key of the group currently being dispatched on main — lane-aware
153
+ // (targetId + thread), unlike mainCurrentTargetId which stays chat-level
154
+ // for fork routing decisions.
155
+ mainCurrentGroupKey;
146
156
  DISPATCHED_MESSAGES_CAP = 5000;
147
157
  // SHUTDOWN_DEADLINE_MS is read by waitForDrain via the configured value
148
158
  // below — kept as instance state so per-runtime configs can override it
@@ -152,6 +162,14 @@ export class ParallAgentGateway {
152
162
  DISPATCH_DEADLINE_MS;
153
163
  constructor(opts) {
154
164
  this.opts = opts;
165
+ if (opts.dispatchContextDir) {
166
+ this.laneLedger = new LaneLedger({
167
+ client: opts.client,
168
+ orgId: opts.config.org_id,
169
+ contextDir: opts.dispatchContextDir,
170
+ log: opts.log,
171
+ });
172
+ }
155
173
  this.SHUTDOWN_DEADLINE_MS = opts.shutdownDeadlineMs ?? 60_000;
156
174
  this.FORK_DEADLINE_MS = opts.forkDeadlineMs ?? 2 * 60 * 60_000;
157
175
  this.DISPATCH_DEADLINE_MS = opts.dispatchDeadlineMs ?? 20 * 60_000;
@@ -228,15 +246,26 @@ export class ParallAgentGateway {
228
246
  if (data.status !== 'todo' && data.status !== 'in_progress')
229
247
  return;
230
248
  try {
231
- const dispatched = await this.handleTaskAssignment(data, data.id);
232
- if (dispatched) {
249
+ // Prefer the exact WorkItem id the server threads through the event —
250
+ // a task PATCH can enqueue sibling task_assign + task_update rows
251
+ // under the same (task_activity, task_id) source tuple, and source-
252
+ // level claim/ack would consume or clear the wrong sibling.
253
+ await this.consumeTypedDispatch(data.dispatch_event_id
254
+ ? { dispatchEventId: data.dispatch_event_id }
255
+ : { sourceType: 'task_activity', sourceId: data.id }, (dispatchEventId) => this.handleTaskAssignment(data, data.id, dispatchEventId), (dispatchEventId) => {
256
+ if (dispatchEventId) {
257
+ this.opts.client
258
+ .ackDispatchByID(this.opts.config.org_id, dispatchEventId)
259
+ .catch(() => { });
260
+ return;
261
+ }
233
262
  this.opts.client
234
263
  .ackDispatch(this.opts.config.org_id, {
235
264
  source_type: 'task_activity',
236
265
  source_id: data.id,
237
266
  })
238
267
  .catch(() => { });
239
- }
268
+ });
240
269
  }
241
270
  catch (err) {
242
271
  this.opts.log?.error(`task dispatch failed for ${data.id}: ${String(err)}`);
@@ -247,10 +276,9 @@ export class ParallAgentGateway {
247
276
  if (!data.source_id || !data.task_id)
248
277
  return;
249
278
  try {
250
- const dispatched = await this.handleTaskComment(data.source_id, data.task_id, data.actor_id, data.delivery_reason);
251
- if (dispatched) {
279
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.handleTaskComment(data.source_id, data.task_id ?? '', data.actor_id, data.delivery_reason), () => {
252
280
  this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
253
- }
281
+ });
254
282
  }
255
283
  catch (err) {
256
284
  this.opts.log?.error(`task comment dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -260,10 +288,9 @@ export class ParallAgentGateway {
260
288
  if (!data.source_id)
261
289
  return;
262
290
  try {
263
- const dispatched = await this.handleWikiComment(data.source_id, data.actor_id, data.delivery_reason);
264
- if (dispatched) {
291
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.handleWikiComment(data.source_id, data.actor_id, data.delivery_reason), () => {
265
292
  this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
266
- }
293
+ });
267
294
  }
268
295
  catch (err) {
269
296
  this.opts.log?.error(`wiki comment dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -273,10 +300,12 @@ export class ParallAgentGateway {
273
300
  if (!data.task_id)
274
301
  return;
275
302
  try {
276
- const dispatched = await this.handleTaskDispatch(data.task_id, data.source_id ?? data.task_id, { allowCreator: true });
277
- if (dispatched) {
303
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, (dispatchEventId) => this.handleTaskDispatch(data.task_id ?? '', data.source_id ?? data.task_id ?? '', {
304
+ allowCreator: true,
305
+ dispatchEventId,
306
+ }), () => {
278
307
  this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
279
- }
308
+ });
280
309
  }
281
310
  catch (err) {
282
311
  this.opts.log?.error(`task update dispatch failed for ${data.task_id}: ${String(err)}`);
@@ -286,10 +315,9 @@ export class ParallAgentGateway {
286
315
  if (!data.source_id)
287
316
  return;
288
317
  try {
289
- const dispatched = await this.fetchAndHandleScheduleFire(data.source_id, data.actor_id);
290
- if (dispatched) {
318
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleScheduleFire(data.source_id, data.actor_id), () => {
291
319
  this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
292
- }
320
+ });
293
321
  }
294
322
  catch (err) {
295
323
  this.opts.log?.error(`schedule fire dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -299,10 +327,9 @@ export class ParallAgentGateway {
299
327
  if (!data.source_id)
300
328
  return;
301
329
  try {
302
- const dispatched = await this.fetchAndHandleExternalTriggerRun(data.source_id);
303
- if (dispatched) {
330
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleExternalTriggerRun(data.source_id), () => {
304
331
  this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
305
- }
332
+ });
306
333
  }
307
334
  catch (err) {
308
335
  this.opts.log?.error(`external trigger dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -312,10 +339,9 @@ export class ParallAgentGateway {
312
339
  if (!data.source_id)
313
340
  return;
314
341
  try {
315
- const dispatched = await this.fetchAndHandleChannelMessage(data.source_id);
316
- if (dispatched) {
342
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleChannelMessage(data.source_id), () => {
317
343
  this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
318
- }
344
+ });
319
345
  }
320
346
  catch (err) {
321
347
  this.opts.log?.error(`channel message dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -325,15 +351,28 @@ export class ParallAgentGateway {
325
351
  if (!data.source_id)
326
352
  return;
327
353
  try {
328
- const dispatched = await this.fetchAndHandleApprovalDecided(data.source_id, data.actor_id, data.chat_id ?? null);
329
- if (dispatched) {
354
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleApprovalDecided(data.source_id, data.actor_id, data.chat_id ?? null), () => {
330
355
  this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
331
- }
356
+ });
332
357
  }
333
358
  catch (err) {
334
359
  this.opts.log?.error(`approval decided dispatch failed for ${data.source_id}: ${String(err)}`);
335
360
  }
336
361
  }
362
+ else if (data.event_type === 'message' &&
363
+ this.laneLedger &&
364
+ data.source_id &&
365
+ data.chat_id) {
366
+ // Ledger re-drive hint: a pending message WorkItem re-published after
367
+ // a same-target lane completed (claim previously refused, or a steer
368
+ // failed). Live first delivery stays on the message.new handler.
369
+ try {
370
+ await this.handleMessageRedrive(data);
371
+ }
372
+ catch (err) {
373
+ this.opts.log?.error(`message re-drive failed for ${data.source_id}: ${String(err)}`);
374
+ }
375
+ }
337
376
  else if (data.event_type !== 'message' && data.event_type !== 'task_assign') {
338
377
  // Truly unknown event_type — log so a newly-added dispatch type
339
378
  // not yet wired here surfaces during runtime testing. "message"
@@ -376,6 +415,43 @@ export class ParallAgentGateway {
376
415
  source_id: sourceId,
377
416
  });
378
417
  }
418
+ /** True when this event's lifecycle is owned by the dispatch lane ledger. */
419
+ usesLaneLedger(event) {
420
+ return this.laneLedger != null && !this.ledgerDisabled && this.laneLedger.handles(event);
421
+ }
422
+ disableLedger(reason) {
423
+ if (this.ledgerDisabled)
424
+ return;
425
+ this.ledgerDisabled = true;
426
+ this.opts.log?.warn(`dispatch ledger unavailable (${reason}) — falling back to legacy received/ack flow`);
427
+ }
428
+ /**
429
+ * Buffer grouping key. Lane-ledger message events group by full lane
430
+ * identity (chat + thread) so a channel lane and a thread lane in the same
431
+ * chat dispatch as separate turns with separate claims; everything else
432
+ * keeps the historical chat-level grouping.
433
+ */
434
+ dispatchGroupKey(event) {
435
+ if (this.usesLaneLedger(event)) {
436
+ // MUST be the lane identity itself (lane-key SSOT): the mid-turn
437
+ // injection gate compares this against mainCurrentGroupKey, and a
438
+ // grouping key that drifted from lane identity would fold two lanes
439
+ // into one turn.
440
+ return this.laneLedger.laneKeyFor(event);
441
+ }
442
+ return event.targetId;
443
+ }
444
+ // Lane-flow protocols live in gateway-lane-flow.ts; these thin delegates
445
+ // keep call sites and tests on the class surface.
446
+ laneFlowHost() {
447
+ return this;
448
+ }
449
+ dispatchLaneGroup(opts) {
450
+ return dispatchLaneGroup(this.laneFlowHost(), opts);
451
+ }
452
+ consumeTypedDispatch(ref, run, ack) {
453
+ return consumeTypedDispatch(this.laneFlowHost(), ref, run, ack);
454
+ }
379
455
  buildDispatchContext(event, sessionKey) {
380
456
  const binding = this.sessionBindings.get(sessionKey);
381
457
  return {
@@ -392,6 +468,7 @@ export class ParallAgentGateway {
392
468
  noReply: event.noReply ?? false,
393
469
  contextFilePath: this.opts.contextFilePathForSession?.(sessionKey),
394
470
  stepIdFilePath: this.opts.stepIdFilePathForSession?.(sessionKey),
471
+ contextDirPath: this.opts.dispatchContextDir,
395
472
  client: this.opts.client,
396
473
  log: this.opts.log,
397
474
  };
@@ -462,7 +539,7 @@ export class ParallAgentGateway {
462
539
  this.opts.log?.warn(`failed to create input step: ${String(err)}`);
463
540
  }
464
541
  }
465
- async createRuntimeStep(sessionId, event, runtimeEvent, stepIdFilePath, contextFilePath) {
542
+ async createRuntimeStep(sessionId, event, runtimeEvent, stepIdFilePath, contextFilePath, laneContextFilePath) {
466
543
  const target = resolveStepTarget(event);
467
544
  try {
468
545
  switch (runtimeEvent.type) {
@@ -509,6 +586,9 @@ export class ParallAgentGateway {
509
586
  else if (stepIdFilePath) {
510
587
  this.writeStepIdFile(stepIdFilePath, step.id);
511
588
  }
589
+ if (laneContextFilePath) {
590
+ this.updateContextFileStepId(laneContextFilePath, step.id);
591
+ }
512
592
  break;
513
593
  }
514
594
  case 'tool_result':
@@ -532,6 +612,9 @@ export class ParallAgentGateway {
532
612
  else if (stepIdFilePath) {
533
613
  this.clearStepIdFile(stepIdFilePath);
534
614
  }
615
+ if (laneContextFilePath) {
616
+ this.updateContextFileStepId(laneContextFilePath, null);
617
+ }
535
618
  break;
536
619
  case 'error':
537
620
  await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
@@ -605,7 +688,7 @@ export class ParallAgentGateway {
605
688
  await this.createInputStep(sessionId, event);
606
689
  }
607
690
  }
608
- async bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath) {
691
+ async bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath, laneContextFilePath) {
609
692
  const runtimeLaneKey = runtimeEvent.runtimeLaneKey || sessionKey;
610
693
  const existing = this.sessionBindings.get(sessionKey);
611
694
  if (existing &&
@@ -654,6 +737,9 @@ export class ParallAgentGateway {
654
737
  if (contextFilePath) {
655
738
  this.updateContextFileSessionId(contextFilePath, session.id);
656
739
  }
740
+ if (laneContextFilePath) {
741
+ this.updateContextFileSessionId(laneContextFilePath, session.id);
742
+ }
657
743
  await this.opts.onSessionBinding?.(binding);
658
744
  return binding;
659
745
  }
@@ -683,14 +769,32 @@ export class ParallAgentGateway {
683
769
  const dispatchContext = this.buildDispatchContext(event, sessionKey);
684
770
  const contextFilePath = dispatchContext.contextFilePath;
685
771
  const stepIdFilePath = dispatchContext.stepIdFilePath;
772
+ // Per-lane context (PRLL_CONTEXT_DIR contract): additive dispatch/lane
773
+ // fields ride along in both files; the per-session file stays as the
774
+ // PRLL_CONTEXT_FILE compat read path.
775
+ const activeLane = this.ledgerDisabled ? undefined : this.laneLedger?.getForEvent(event);
776
+ const laneContextFilePath = activeLane
777
+ ? this.laneLedger?.laneContextPath(activeLane)
778
+ : undefined;
779
+ const contextBody = {
780
+ session_id: dispatchContext.sessionId ?? null,
781
+ chat_id: dispatchContext.chatId ?? null,
782
+ trigger_message_id: dispatchContext.triggerMessageId ?? null,
783
+ no_reply: dispatchContext.noReply,
784
+ step_id: null,
785
+ dispatch_event_id: activeLane?.typedDispatchEventId ?? activeLane?.folded.get(event.messageId) ?? null,
786
+ lane: activeLane?.lane ?? null,
787
+ target_uri: activeLane?.targetUri ?? null,
788
+ thread_root_id: activeLane?.threadRootId ?? null,
789
+ // Typed binding hint for the CLI: which task this dispatch is about
790
+ // (parall task update attaches the typed effect only on a match).
791
+ task_id: event.type === 'task' ? event.targetId : null,
792
+ };
686
793
  if (contextFilePath) {
687
- this.writeContextFile(contextFilePath, {
688
- session_id: dispatchContext.sessionId ?? null,
689
- chat_id: dispatchContext.chatId ?? null,
690
- trigger_message_id: dispatchContext.triggerMessageId ?? null,
691
- no_reply: dispatchContext.noReply,
692
- step_id: null,
693
- });
794
+ this.writeContextFile(contextFilePath, contextBody);
795
+ }
796
+ if (laneContextFilePath) {
797
+ this.writeContextFile(laneContextFilePath, contextBody);
694
798
  }
695
799
  // sync: no await between the shuttingDown check above and this increment
696
800
  // — JS event loop is single-threaded, so shutdown() cannot interleave
@@ -723,7 +827,7 @@ export class ParallAgentGateway {
723
827
  })) {
724
828
  if (runtimeEvent.type === 'runtime_session') {
725
829
  const priorAgentSessionId = binding?.agentSessionId;
726
- binding = await this.bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath);
830
+ binding = await this.bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath, laneContextFilePath);
727
831
  if (event.targetType === 'channel_conversation' &&
728
832
  binding.agentSessionId !== priorAgentSessionId) {
729
833
  // Record the durable chv_ ↔ ase_ mapping (ops drill-down from a
@@ -766,6 +870,12 @@ export class ParallAgentGateway {
766
870
  await this.createInputStep(binding.agentSessionId, event);
767
871
  inputStepsCreated = true;
768
872
  }
873
+ // Long-turn keepalive: any runtime activity renews the lane lease
874
+ // (throttled in the ledger) so a legitimately long turn is not
875
+ // dethroned at TTL.
876
+ if (activeLane && !this.ledgerDisabled) {
877
+ this.laneLedger?.maybeRenew(activeLane);
878
+ }
769
879
  if (captureText && runtimeEvent.type === 'text' && runtimeEvent.text) {
770
880
  captureText.push(runtimeEvent.text);
771
881
  }
@@ -786,7 +896,7 @@ export class ParallAgentGateway {
786
896
  pendingSendCallIds.delete(runtimeEvent.callId)) {
787
897
  recordMessageSend(sessionKey, !runtimeEvent.error);
788
898
  }
789
- await this.createRuntimeStep(binding.agentSessionId, event, runtimeEvent, stepIdFilePath, contextFilePath);
899
+ await this.createRuntimeStep(binding.agentSessionId, event, runtimeEvent, stepIdFilePath, contextFilePath, laneContextFilePath);
790
900
  }
791
901
  if (!binding) {
792
902
  binding = this.sessionBindings.get(sessionKey);
@@ -809,7 +919,7 @@ export class ParallAgentGateway {
809
919
  await this.createRuntimeStep(binding.agentSessionId, event, {
810
920
  type: 'error',
811
921
  message: `Dispatch failed: ${String(err)}`,
812
- }, stepIdFilePath, contextFilePath);
922
+ }, stepIdFilePath, contextFilePath, laneContextFilePath);
813
923
  }
814
924
  catch (stepErr) {
815
925
  if (this.isSessionNotLiveError(stepErr))
@@ -865,6 +975,9 @@ export class ParallAgentGateway {
865
975
  else if (stepIdFilePath) {
866
976
  this.clearStepIdFile(stepIdFilePath);
867
977
  }
978
+ if (laneContextFilePath) {
979
+ this.updateContextFileStepId(laneContextFilePath, null);
980
+ }
868
981
  this.inFlightDispatches--;
869
982
  if (this.inFlightDispatches === 0 && this.drainResolvers.length > 0) {
870
983
  const resolvers = this.drainResolvers.splice(0);
@@ -930,13 +1043,44 @@ export class ParallAgentGateway {
930
1043
  item.resolve(false);
931
1044
  break;
932
1045
  }
933
- const items = fork.queue.splice(0);
1046
+ // Lane-ledger message events batch per lane identity (chat + thread)
1047
+ // so a fork's claim/steer/complete always addresses one lane.
1048
+ let items;
1049
+ const head = fork.queue[0];
1050
+ if (head && this.usesLaneLedger(head.event)) {
1051
+ const headKey = this.dispatchGroupKey(head.event);
1052
+ const splitAt = fork.queue.findIndex((it) => this.dispatchGroupKey(it.event) !== headKey);
1053
+ items = splitAt === -1 ? fork.queue.splice(0) : fork.queue.splice(0, splitAt);
1054
+ }
1055
+ else {
1056
+ items = fork.queue.splice(0);
1057
+ }
934
1058
  const events = items.map((item) => item.event);
935
1059
  const last = events[events.length - 1];
936
1060
  const earlier = events.slice(0, -1);
937
1061
  try {
938
1062
  const batchText = [];
939
- const dispatched = await this.runDispatch(last, fork.fork.sessionKey, buildForkScopePrefix(last) + buildEventBody(last), earlier, batchText);
1063
+ let dispatched;
1064
+ if (this.usesLaneLedger(last)) {
1065
+ const outcome = await this.dispatchLaneGroup({
1066
+ events,
1067
+ sessionKey: fork.fork.sessionKey,
1068
+ body: buildForkScopePrefix(last) + buildEventBody(last),
1069
+ earlier,
1070
+ captureText: batchText,
1071
+ hasMoreLocal: () => fork.queue.length > 0,
1072
+ });
1073
+ if (outcome === 'foreign') {
1074
+ // Another pod owns the lane — the events stay pending server-side.
1075
+ for (const item of items)
1076
+ item.resolve(false);
1077
+ break;
1078
+ }
1079
+ dispatched = outcome === 'dispatched';
1080
+ }
1081
+ else {
1082
+ dispatched = await this.runDispatch(last, fork.fork.sessionKey, buildForkScopePrefix(last) + buildEventBody(last), earlier, batchText);
1083
+ }
940
1084
  if (!dispatched) {
941
1085
  // Shutdown short-circuit — resolve un-acked so the server requeues
942
1086
  // for the replacement pod and stop draining further items.
@@ -1052,9 +1196,10 @@ export class ParallAgentGateway {
1052
1196
  this.opts.log?.info(`drainMainBuffer halted (shutting down) — ${this.dispatchState.mainBuffer.length} buffered, ${this.dispatchState.pendingForkResults.length} pending fork results left for catch-up`);
1053
1197
  break;
1054
1198
  }
1055
- const targetId = this.dispatchState.mainBuffer[0].targetId;
1199
+ const groupKey = this.dispatchGroupKey(this.dispatchState.mainBuffer[0]);
1056
1200
  const events = [];
1057
- while (this.dispatchState.mainBuffer[0]?.targetId === targetId) {
1201
+ while (this.dispatchState.mainBuffer[0] &&
1202
+ this.dispatchGroupKey(this.dispatchState.mainBuffer[0]) === groupKey) {
1058
1203
  events.push(this.dispatchState.mainBuffer.shift());
1059
1204
  }
1060
1205
  const event = events[events.length - 1];
@@ -1065,7 +1210,41 @@ export class ParallAgentGateway {
1065
1210
  : this.dispatchState.pendingForkResults.splice(0);
1066
1211
  const forkPrefix = buildForkResultPrefix(pendingFork);
1067
1212
  this.dispatchState.mainCurrentTargetId = event.targetId;
1213
+ this.mainCurrentGroupKey = groupKey;
1068
1214
  this.dispatchState.mainPreDispatchBranchPoint = this.opts.dispatchAdapter.getBranchPoint?.(this.opts.runtimeKey);
1215
+ if (this.usesLaneLedger(event)) {
1216
+ let outcome;
1217
+ try {
1218
+ outcome = await this.dispatchLaneGroup({
1219
+ events,
1220
+ sessionKey: this.opts.runtimeKey,
1221
+ body: forkPrefix + buildEventBody(event),
1222
+ earlier,
1223
+ hasMoreLocal: () => this.dispatchState.mainBuffer.some((e) => this.dispatchGroupKey(e) === groupKey),
1224
+ });
1225
+ }
1226
+ catch (err) {
1227
+ // The lane was released inside dispatchLaneGroup — members are
1228
+ // pending again server-side; drop them locally and move on.
1229
+ this.opts.log?.error(`lane dispatch failed for ${event.messageId}: ${String(err)}`);
1230
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1231
+ for (const ev of events)
1232
+ this.dispatchedMessages.delete(ev.messageId);
1233
+ continue;
1234
+ }
1235
+ if (outcome === 'shutdown') {
1236
+ this.dispatchState.mainBuffer.unshift(...events);
1237
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1238
+ break;
1239
+ }
1240
+ if (outcome === 'foreign') {
1241
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1242
+ continue;
1243
+ }
1244
+ // Dispatched — resolution happened server-side (reply cover or
1245
+ // no_action sweep); no legacy acks.
1246
+ continue;
1247
+ }
1069
1248
  try {
1070
1249
  await this.emitDispatchReceived(event);
1071
1250
  }
@@ -1101,6 +1280,7 @@ export class ParallAgentGateway {
1101
1280
  this.draining = false;
1102
1281
  this.dispatchState.mainDispatching = false;
1103
1282
  this.dispatchState.mainCurrentTargetId = undefined;
1283
+ this.mainCurrentGroupKey = undefined;
1104
1284
  this.dispatchState.mainPreDispatchBranchPoint = undefined;
1105
1285
  if (!this.shuttingDown && this.dispatchState.mainBuffer.length > 0) {
1106
1286
  // Opportunistic re-drain — best-effort, not a recovery deadline, so it
@@ -1129,10 +1309,40 @@ export class ParallAgentGateway {
1129
1309
  const forkPrefix = buildForkResultPrefix(pendingFork);
1130
1310
  this.dispatchState.mainDispatching = true;
1131
1311
  this.dispatchState.mainCurrentTargetId = event.targetId;
1312
+ this.mainCurrentGroupKey = this.dispatchGroupKey(event);
1132
1313
  // Snapshot the on-disk branch point BEFORE runDispatch starts writing
1133
1314
  // to the session file. Fork sessions created while main is in-flight
1134
1315
  // use this to branch from the clean pre-dispatch state.
1135
1316
  this.dispatchState.mainPreDispatchBranchPoint = this.opts.dispatchAdapter.getBranchPoint?.(this.opts.runtimeKey);
1317
+ if (this.usesLaneLedger(event)) {
1318
+ // Ledger flow: claim replaces mark-received; complete/reply replace
1319
+ // acks. A foreign incumbent leaves the event pending for re-drive.
1320
+ let outcome = 'shutdown';
1321
+ try {
1322
+ try {
1323
+ outcome = await this.dispatchLaneGroup({
1324
+ events: [event],
1325
+ sessionKey: this.opts.runtimeKey,
1326
+ body: forkPrefix + buildEventBody(event),
1327
+ earlier: [],
1328
+ hasMoreLocal: () => this.dispatchState.mainBuffer.some((e) => this.dispatchGroupKey(e) === this.dispatchGroupKey(event)),
1329
+ });
1330
+ }
1331
+ catch (err) {
1332
+ // Same failure contract as the buffered-group path: accumulated
1333
+ // fork results must survive a failed turn for later replay.
1334
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1335
+ throw err;
1336
+ }
1337
+ if (outcome !== 'dispatched') {
1338
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1339
+ }
1340
+ }
1341
+ finally {
1342
+ await this.drainMainBuffer();
1343
+ }
1344
+ return outcome === 'dispatched';
1345
+ }
1136
1346
  try {
1137
1347
  await this.emitDispatchReceived(event);
1138
1348
  }
@@ -1140,6 +1350,7 @@ export class ParallAgentGateway {
1140
1350
  this.opts.log?.warn?.(`mark-received failed, leaving unacked for retry: ${String(err)}`);
1141
1351
  this.dispatchState.mainDispatching = false;
1142
1352
  this.dispatchState.mainCurrentTargetId = undefined;
1353
+ this.mainCurrentGroupKey = undefined;
1143
1354
  this.dispatchState.mainPreDispatchBranchPoint = undefined;
1144
1355
  this.dispatchState.pendingForkResults.unshift(...pendingFork);
1145
1356
  return false;
@@ -1166,7 +1377,20 @@ export class ParallAgentGateway {
1166
1377
  // arrival order is preserved and the event cannot be orphaned in a
1167
1378
  // gap between the steer await and the push.
1168
1379
  this.dispatchState.mainBuffer.push(event);
1169
- if (this.dispatchState.mainCurrentTargetId === event.targetId &&
1380
+ if (this.usesLaneLedger(event)) {
1381
+ // Ledger flow: fold into the live lane server-side FIRST, then
1382
+ // inject. An un-folded injection is forbidden (the pending WorkItem
1383
+ // would re-drive after complete and be handled twice); a failed
1384
+ // fold leaves the event buffered — the drain claims it as its own
1385
+ // turn. Injection requires an exact lane match (same chat AND same
1386
+ // thread) — a thread message never rides a channel turn.
1387
+ if (this.mainCurrentGroupKey === this.dispatchGroupKey(event) &&
1388
+ (await this.laneLedger?.steerLive(event)) &&
1389
+ (await this.opts.dispatchAdapter.enqueueDuringDispatch?.(this.opts.runtimeKey, buildEventBody(event)))) {
1390
+ this.opts.log?.info(`steer folded+injected for ${event.messageId} (will drain for bookkeeping)`);
1391
+ }
1392
+ }
1393
+ else if (this.dispatchState.mainCurrentTargetId === event.targetId &&
1170
1394
  (await this.opts.dispatchAdapter.enqueueDuringDispatch?.(this.opts.runtimeKey, buildEventBody(event)))) {
1171
1395
  this.opts.log?.info(`steer injected for ${event.messageId} (will drain for bookkeeping)`);
1172
1396
  }
@@ -1352,9 +1576,13 @@ export class ParallAgentGateway {
1352
1576
  try {
1353
1577
  const dispatched = await this.handleInboundEvent(event);
1354
1578
  if (dispatched) {
1355
- this.opts.client
1356
- .ackDispatch(this.opts.config.org_id, { source_type: 'message', source_id: data.id })
1357
- .catch(() => { });
1579
+ // Ledger events resolve server-side (reply cover / no_action sweep);
1580
+ // the legacy by-source ack is only for non-ledger runtimes.
1581
+ if (!this.usesLaneLedger(event)) {
1582
+ this.opts.client
1583
+ .ackDispatch(this.opts.config.org_id, { source_type: 'message', source_id: data.id })
1584
+ .catch(() => { });
1585
+ }
1358
1586
  }
1359
1587
  else {
1360
1588
  this.dispatchedMessages.delete(data.id);
@@ -1365,7 +1593,21 @@ export class ParallAgentGateway {
1365
1593
  this.dispatchedMessages.delete(data.id);
1366
1594
  }
1367
1595
  }
1368
- async handleTaskAssignment(task, ackSourceId) {
1596
+ // Ledger re-drive consumption: dispatch.new message hints re-enter the
1597
+ // shared WorkItem consumption path (same protocol as catch-up).
1598
+ async handleMessageRedrive(item) {
1599
+ if (!item.chat_id || !item.source_id)
1600
+ return;
1601
+ await this.consumeMessageWorkItem({
1602
+ id: item.id,
1603
+ source_id: item.source_id,
1604
+ chat_id: item.chat_id,
1605
+ });
1606
+ }
1607
+ consumeMessageWorkItem(item) {
1608
+ return consumeMessageWorkItem(this.laneFlowHost(), item);
1609
+ }
1610
+ async handleTaskAssignment(task, ackSourceId, dispatchEventId) {
1369
1611
  if (this.shuttingDown)
1370
1612
  return false; // drain window — let server requeue via catch-up
1371
1613
  const dedupeKey = `${task.id}:${task.updated_at}`;
@@ -1395,6 +1637,7 @@ export class ParallAgentGateway {
1395
1637
  sentAt: task.updated_at ?? task.created_at,
1396
1638
  ackSourceType: 'task_activity',
1397
1639
  ackSourceId,
1640
+ dispatchEventId,
1398
1641
  };
1399
1642
  const dispatched = await this.handleInboundEvent(event);
1400
1643
  if (!dispatched) {
@@ -1419,7 +1662,7 @@ export class ParallAgentGateway {
1419
1662
  this.opts.log?.info(`skipping stale task dispatch ${ackSourceId ?? taskId} — assigned to ${task.assignee_id}, creator ${task.creator_id}`);
1420
1663
  return true;
1421
1664
  }
1422
- return this.handleTaskAssignment(task, ackSourceId);
1665
+ return this.handleTaskAssignment(task, ackSourceId, opts.dispatchEventId);
1423
1666
  }
1424
1667
  async handleTaskComment(commentId, taskId, actorId, deliveryReason) {
1425
1668
  if (this.shuttingDown)
@@ -1872,10 +2115,14 @@ export class ParallAgentGateway {
1872
2115
  }
1873
2116
  processed++;
1874
2117
  try {
1875
- let dispatched = false;
2118
+ const ackItem = () => {
2119
+ this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
2120
+ };
1876
2121
  if (item.event_type === 'task_assign' && item.task_id) {
1877
2122
  try {
1878
- dispatched = await this.handleTaskDispatch(item.task_id, item.source_id ?? item.task_id);
2123
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.handleTaskDispatch(item.task_id ?? '', item.source_id ?? item.task_id ?? '', {
2124
+ dispatchEventId,
2125
+ }), ackItem);
1879
2126
  }
1880
2127
  catch (err) {
1881
2128
  this.opts.log?.warn(`catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
@@ -1884,7 +2131,10 @@ export class ParallAgentGateway {
1884
2131
  }
1885
2132
  else if (item.event_type === 'task_update' && item.task_id) {
1886
2133
  try {
1887
- dispatched = await this.handleTaskDispatch(item.task_id, item.source_id ?? item.task_id, { allowCreator: true });
2134
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.handleTaskDispatch(item.task_id ?? '', item.source_id ?? item.task_id ?? '', {
2135
+ allowCreator: true,
2136
+ dispatchEventId,
2137
+ }), ackItem);
1888
2138
  }
1889
2139
  catch (err) {
1890
2140
  this.opts.log?.warn(`catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
@@ -1892,64 +2142,29 @@ export class ParallAgentGateway {
1892
2142
  }
1893
2143
  }
1894
2144
  else if (item.event_type === 'task_comment' && item.source_id && item.task_id) {
1895
- dispatched = await this.handleTaskComment(item.source_id, item.task_id, item.actor_id, item.delivery_reason);
2145
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.handleTaskComment(item.source_id, item.task_id ?? '', item.actor_id, item.delivery_reason), ackItem);
1896
2146
  }
1897
2147
  else if (item.event_type === 'wiki_comment' && item.source_id) {
1898
- dispatched = await this.handleWikiComment(item.source_id, item.actor_id, item.delivery_reason);
2148
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.handleWikiComment(item.source_id, item.actor_id, item.delivery_reason), ackItem);
1899
2149
  }
1900
2150
  else if (item.event_type === 'schedule.fire' && item.source_id) {
1901
- dispatched = await this.fetchAndHandleScheduleFire(item.source_id, item.actor_id);
2151
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleScheduleFire(item.source_id, item.actor_id), ackItem);
1902
2152
  }
1903
2153
  else if (item.event_type === 'external_trigger' && item.source_id) {
1904
- dispatched = await this.fetchAndHandleExternalTriggerRun(item.source_id);
2154
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleExternalTriggerRun(item.source_id), ackItem);
1905
2155
  }
1906
2156
  else if (item.event_type === 'channel_message' && item.source_id) {
1907
- dispatched = await this.fetchAndHandleChannelMessage(item.source_id);
2157
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleChannelMessage(item.source_id), ackItem);
1908
2158
  }
1909
2159
  else if (item.event_type === 'approval_decided' && item.source_id) {
1910
- dispatched = await this.fetchAndHandleApprovalDecided(item.source_id, item.actor_id, item.chat_id ?? null);
2160
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleApprovalDecided(item.source_id, item.actor_id, item.chat_id ?? null), ackItem);
1911
2161
  }
1912
2162
  else if (item.event_type === 'message' && item.source_id && item.chat_id) {
1913
- if (!this.tryClaimMessage(item.source_id))
1914
- continue;
1915
- let msg = null;
1916
- let msgFetchFailed = false;
1917
- try {
1918
- msg = await this.opts.client.getMessage(item.source_id);
1919
- }
1920
- catch (err) {
1921
- const status = err?.status;
1922
- if (status === 404) {
1923
- msg = null;
1924
- }
1925
- else {
1926
- msgFetchFailed = true;
1927
- this.opts.log?.warn(`catch-up message fetch failed for ${item.source_id}, leaving pending: ${String(err)}`);
1928
- }
1929
- }
1930
- if (msgFetchFailed) {
1931
- this.dispatchedMessages.delete(item.source_id);
1932
- continue;
1933
- }
1934
- if (!msg || msg.sender_id === this.opts.agentUserId) {
1935
- this.dispatchedMessages.delete(item.source_id);
1936
- this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
1937
- continue;
1938
- }
1939
- const decision = await this.buildMessageDispatchDecision(item.chat_id, msg);
1940
- if (decision.action === 'retry') {
1941
- this.dispatchedMessages.delete(item.source_id);
1942
- continue;
1943
- }
1944
- if (decision.action === 'skip') {
1945
- this.dispatchedMessages.delete(item.source_id);
1946
- this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
1947
- continue;
1948
- }
1949
- dispatched = await this.handleInboundEvent(decision.event);
1950
- }
1951
- if (dispatched) {
1952
- this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
2163
+ await this.consumeMessageWorkItem({
2164
+ id: item.id,
2165
+ source_id: item.source_id,
2166
+ chat_id: item.chat_id,
2167
+ });
1953
2168
  }
1954
2169
  }
1955
2170
  catch (err) {
@@ -2018,6 +2233,20 @@ export class ParallAgentGateway {
2018
2233
  this.abortFork(targetId, 'ws reconnect');
2019
2234
  }
2020
2235
  }
2236
+ // Reconnect with nothing in flight: interrupted turns can't resume, so
2237
+ // hand their lane members back to the pending pool before catch-up
2238
+ // re-claims (an in-flight turn keeps its lanes — it is still the owner).
2239
+ if (this.laneLedger && this.inFlightDispatches === 0 && this.laneLedger.activeCount > 0) {
2240
+ log?.info(`releasing ${this.laneLedger.activeCount} stale lane(s) on reconnect`);
2241
+ await this.laneLedger.releaseAll();
2242
+ }
2243
+ // Re-probe the ledger each connection: a sticky downgrade from a
2244
+ // transient edge 404 during a rolling deploy must not outlive the
2245
+ // connection that observed it.
2246
+ if (this.laneLedger && this.ledgerDisabled) {
2247
+ log?.info('re-probing dispatch ledger after reconnect (was disabled)');
2248
+ this.ledgerDisabled = false;
2249
+ }
2021
2250
  const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
2022
2251
  try {
2023
2252
  const count = await fetchAllChats(client, config.org_id, this.chatInfoMap);
@@ -2097,6 +2326,13 @@ export class ParallAgentGateway {
2097
2326
  }
2098
2327
  if (this.heartbeatTimer)
2099
2328
  clearInterval(this.heartbeatTimer);
2329
+ // Completed turns already released their lanes; whatever is left belongs
2330
+ // to interrupted work — hand the members back so the replacement pod
2331
+ // re-claims immediately instead of waiting out the lease.
2332
+ if (this.laneLedger && this.laneLedger.activeCount > 0) {
2333
+ this.opts.log?.info(`releasing ${this.laneLedger.activeCount} lane(s) on shutdown`);
2334
+ await this.laneLedger.releaseAll();
2335
+ }
2100
2336
  await this.opts.onBeforeDisconnect?.();
2101
2337
  this.opts.ws.disconnect();
2102
2338
  this.opts.log?.info(`disconnected`);