@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
@@ -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';
@@ -53,6 +55,9 @@ function resolveStepTarget(event) {
53
55
  if (event.type === 'external_trigger' || event.targetId.startsWith('xtr_')) {
54
56
  return { target_type: 'external_trigger', target_id: event.targetId };
55
57
  }
58
+ if (event.type === 'channel_message' || event.targetId.startsWith('chv_')) {
59
+ return { target_type: 'channel_conversation', target_id: event.targetId };
60
+ }
56
61
  if (event.type === 'wiki_comment') {
57
62
  // target_id is the full wiki target_uri (scheme-stripped routing key). The
58
63
  // server stores target_type freely and only publishes step WS events /
@@ -115,6 +120,9 @@ export class ParallAgentGateway {
115
120
  opts;
116
121
  chatInfoMap = new Map();
117
122
  dispatchedTasks = new Set();
123
+ // connection id → provider alias, for channel_message prompt labeling
124
+ // (stable mapping; avoids one connection fetch per inbound message).
125
+ channelConnectionProviders = new Map();
118
126
  dispatchedMessages = new Set();
119
127
  forkStates = new Map();
120
128
  dispatchState = {
@@ -137,6 +145,14 @@ export class ParallAgentGateway {
137
145
  inFlightDispatches = 0;
138
146
  drainResolvers = [];
139
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;
140
156
  DISPATCHED_MESSAGES_CAP = 5000;
141
157
  // SHUTDOWN_DEADLINE_MS is read by waitForDrain via the configured value
142
158
  // below — kept as instance state so per-runtime configs can override it
@@ -146,6 +162,14 @@ export class ParallAgentGateway {
146
162
  DISPATCH_DEADLINE_MS;
147
163
  constructor(opts) {
148
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
+ }
149
173
  this.SHUTDOWN_DEADLINE_MS = opts.shutdownDeadlineMs ?? 60_000;
150
174
  this.FORK_DEADLINE_MS = opts.forkDeadlineMs ?? 2 * 60 * 60_000;
151
175
  this.DISPATCH_DEADLINE_MS = opts.dispatchDeadlineMs ?? 20 * 60_000;
@@ -222,15 +246,26 @@ export class ParallAgentGateway {
222
246
  if (data.status !== 'todo' && data.status !== 'in_progress')
223
247
  return;
224
248
  try {
225
- const dispatched = await this.handleTaskAssignment(data, data.id);
226
- 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
+ }
227
262
  this.opts.client
228
263
  .ackDispatch(this.opts.config.org_id, {
229
264
  source_type: 'task_activity',
230
265
  source_id: data.id,
231
266
  })
232
267
  .catch(() => { });
233
- }
268
+ });
234
269
  }
235
270
  catch (err) {
236
271
  this.opts.log?.error(`task dispatch failed for ${data.id}: ${String(err)}`);
@@ -241,10 +276,9 @@ export class ParallAgentGateway {
241
276
  if (!data.source_id || !data.task_id)
242
277
  return;
243
278
  try {
244
- const dispatched = await this.handleTaskComment(data.source_id, data.task_id, data.actor_id, data.delivery_reason);
245
- if (dispatched) {
279
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.handleTaskComment(data.source_id, data.task_id ?? '', data.actor_id, data.delivery_reason), () => {
246
280
  this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
247
- }
281
+ });
248
282
  }
249
283
  catch (err) {
250
284
  this.opts.log?.error(`task comment dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -254,10 +288,9 @@ export class ParallAgentGateway {
254
288
  if (!data.source_id)
255
289
  return;
256
290
  try {
257
- const dispatched = await this.handleWikiComment(data.source_id, data.actor_id, data.delivery_reason);
258
- if (dispatched) {
291
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.handleWikiComment(data.source_id, data.actor_id, data.delivery_reason), () => {
259
292
  this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
260
- }
293
+ });
261
294
  }
262
295
  catch (err) {
263
296
  this.opts.log?.error(`wiki comment dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -267,10 +300,12 @@ export class ParallAgentGateway {
267
300
  if (!data.task_id)
268
301
  return;
269
302
  try {
270
- const dispatched = await this.handleTaskDispatch(data.task_id, data.source_id ?? data.task_id, { allowCreator: true });
271
- 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
+ }), () => {
272
307
  this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
273
- }
308
+ });
274
309
  }
275
310
  catch (err) {
276
311
  this.opts.log?.error(`task update dispatch failed for ${data.task_id}: ${String(err)}`);
@@ -280,10 +315,9 @@ export class ParallAgentGateway {
280
315
  if (!data.source_id)
281
316
  return;
282
317
  try {
283
- const dispatched = await this.fetchAndHandleScheduleFire(data.source_id, data.actor_id);
284
- if (dispatched) {
318
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleScheduleFire(data.source_id, data.actor_id), () => {
285
319
  this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
286
- }
320
+ });
287
321
  }
288
322
  catch (err) {
289
323
  this.opts.log?.error(`schedule fire dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -293,28 +327,52 @@ export class ParallAgentGateway {
293
327
  if (!data.source_id)
294
328
  return;
295
329
  try {
296
- const dispatched = await this.fetchAndHandleExternalTriggerRun(data.source_id);
297
- if (dispatched) {
330
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleExternalTriggerRun(data.source_id), () => {
298
331
  this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
299
- }
332
+ });
300
333
  }
301
334
  catch (err) {
302
335
  this.opts.log?.error(`external trigger dispatch failed for ${data.source_id}: ${String(err)}`);
303
336
  }
304
337
  }
338
+ else if (data.event_type === 'channel_message') {
339
+ if (!data.source_id)
340
+ return;
341
+ try {
342
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleChannelMessage(data.source_id), () => {
343
+ this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
344
+ });
345
+ }
346
+ catch (err) {
347
+ this.opts.log?.error(`channel message dispatch failed for ${data.source_id}: ${String(err)}`);
348
+ }
349
+ }
305
350
  else if (data.event_type === 'approval_decided') {
306
351
  if (!data.source_id)
307
352
  return;
308
353
  try {
309
- const dispatched = await this.fetchAndHandleApprovalDecided(data.source_id, data.actor_id, data.chat_id ?? null);
310
- if (dispatched) {
354
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleApprovalDecided(data.source_id, data.actor_id, data.chat_id ?? null), () => {
311
355
  this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
312
- }
356
+ });
313
357
  }
314
358
  catch (err) {
315
359
  this.opts.log?.error(`approval decided dispatch failed for ${data.source_id}: ${String(err)}`);
316
360
  }
317
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
+ }
318
376
  else if (data.event_type !== 'message' && data.event_type !== 'task_assign') {
319
377
  // Truly unknown event_type — log so a newly-added dispatch type
320
378
  // not yet wired here surfaces during runtime testing. "message"
@@ -357,6 +415,43 @@ export class ParallAgentGateway {
357
415
  source_id: sourceId,
358
416
  });
359
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
+ }
360
455
  buildDispatchContext(event, sessionKey) {
361
456
  const binding = this.sessionBindings.get(sessionKey);
362
457
  return {
@@ -373,6 +468,7 @@ export class ParallAgentGateway {
373
468
  noReply: event.noReply ?? false,
374
469
  contextFilePath: this.opts.contextFilePathForSession?.(sessionKey),
375
470
  stepIdFilePath: this.opts.stepIdFilePathForSession?.(sessionKey),
471
+ contextDirPath: this.opts.dispatchContextDir,
376
472
  client: this.opts.client,
377
473
  log: this.opts.log,
378
474
  };
@@ -400,9 +496,11 @@ export class ParallAgentGateway {
400
496
  ? 'schedule_fire'
401
497
  : event.type === 'external_trigger'
402
498
  ? 'external_trigger'
403
- : event.type === 'approval'
404
- ? 'approval_decided'
405
- : 'mention',
499
+ : event.type === 'channel_message'
500
+ ? 'channel_message'
501
+ : event.type === 'approval'
502
+ ? 'approval_decided'
503
+ : 'mention',
406
504
  trigger_ref: event.type === 'task'
407
505
  ? { task_id: event.targetId }
408
506
  : event.type === 'task_comment'
@@ -418,9 +516,16 @@ export class ParallAgentGateway {
418
516
  connection_id: event.externalConnectionId,
419
517
  ingress_event_id: event.externalIngressEventId,
420
518
  }
421
- : event.type === 'approval'
422
- ? { approval_id: event.messageId }
423
- : { message_id: event.messageId },
519
+ : event.type === 'channel_message'
520
+ ? {
521
+ conversation_id: event.targetId,
522
+ channel_message_id: event.messageId,
523
+ provider: event.channelProvider,
524
+ external_conversation_id: event.channelExternalConversationId,
525
+ }
526
+ : event.type === 'approval'
527
+ ? { approval_id: event.messageId }
528
+ : { message_id: event.messageId },
424
529
  sender_id: event.senderId,
425
530
  sender_name: event.senderName,
426
531
  summary: event.body.substring(0, 200),
@@ -434,7 +539,7 @@ export class ParallAgentGateway {
434
539
  this.opts.log?.warn(`failed to create input step: ${String(err)}`);
435
540
  }
436
541
  }
437
- async createRuntimeStep(sessionId, event, runtimeEvent, stepIdFilePath, contextFilePath) {
542
+ async createRuntimeStep(sessionId, event, runtimeEvent, stepIdFilePath, contextFilePath, laneContextFilePath) {
438
543
  const target = resolveStepTarget(event);
439
544
  try {
440
545
  switch (runtimeEvent.type) {
@@ -481,6 +586,9 @@ export class ParallAgentGateway {
481
586
  else if (stepIdFilePath) {
482
587
  this.writeStepIdFile(stepIdFilePath, step.id);
483
588
  }
589
+ if (laneContextFilePath) {
590
+ this.updateContextFileStepId(laneContextFilePath, step.id);
591
+ }
484
592
  break;
485
593
  }
486
594
  case 'tool_result':
@@ -504,6 +612,9 @@ export class ParallAgentGateway {
504
612
  else if (stepIdFilePath) {
505
613
  this.clearStepIdFile(stepIdFilePath);
506
614
  }
615
+ if (laneContextFilePath) {
616
+ this.updateContextFileStepId(laneContextFilePath, null);
617
+ }
507
618
  break;
508
619
  case 'error':
509
620
  await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
@@ -577,7 +688,7 @@ export class ParallAgentGateway {
577
688
  await this.createInputStep(sessionId, event);
578
689
  }
579
690
  }
580
- async bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath) {
691
+ async bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath, laneContextFilePath) {
581
692
  const runtimeLaneKey = runtimeEvent.runtimeLaneKey || sessionKey;
582
693
  const existing = this.sessionBindings.get(sessionKey);
583
694
  if (existing &&
@@ -626,6 +737,9 @@ export class ParallAgentGateway {
626
737
  if (contextFilePath) {
627
738
  this.updateContextFileSessionId(contextFilePath, session.id);
628
739
  }
740
+ if (laneContextFilePath) {
741
+ this.updateContextFileSessionId(laneContextFilePath, session.id);
742
+ }
629
743
  await this.opts.onSessionBinding?.(binding);
630
744
  return binding;
631
745
  }
@@ -655,14 +769,32 @@ export class ParallAgentGateway {
655
769
  const dispatchContext = this.buildDispatchContext(event, sessionKey);
656
770
  const contextFilePath = dispatchContext.contextFilePath;
657
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
+ };
658
793
  if (contextFilePath) {
659
- this.writeContextFile(contextFilePath, {
660
- session_id: dispatchContext.sessionId ?? null,
661
- chat_id: dispatchContext.chatId ?? null,
662
- trigger_message_id: dispatchContext.triggerMessageId ?? null,
663
- no_reply: dispatchContext.noReply,
664
- step_id: null,
665
- });
794
+ this.writeContextFile(contextFilePath, contextBody);
795
+ }
796
+ if (laneContextFilePath) {
797
+ this.writeContextFile(laneContextFilePath, contextBody);
666
798
  }
667
799
  // sync: no await between the shuttingDown check above and this increment
668
800
  // — JS event loop is single-threaded, so shutdown() cannot interleave
@@ -694,7 +826,20 @@ export class ParallAgentGateway {
694
826
  context: dispatchContext,
695
827
  })) {
696
828
  if (runtimeEvent.type === 'runtime_session') {
697
- binding = await this.bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath);
829
+ const priorAgentSessionId = binding?.agentSessionId;
830
+ binding = await this.bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath, laneContextFilePath);
831
+ if (event.targetType === 'channel_conversation' &&
832
+ binding.agentSessionId !== priorAgentSessionId) {
833
+ // Record the durable chv_ ↔ ase_ mapping (ops drill-down from a
834
+ // conversation into its session). Best-effort bookkeeping —
835
+ // never fail the dispatch over it.
836
+ try {
837
+ await this.opts.client.setChannelConversationSession(this.opts.config.org_id, event.targetId, binding.agentSessionId);
838
+ }
839
+ catch (err) {
840
+ this.opts.log?.warn(`failed to record session mapping for channel conversation ${event.targetId}: ${String(err)}`);
841
+ }
842
+ }
698
843
  if (!inputStepsCreated) {
699
844
  // Persist input steps for "earlier events" (batched events that arrived
700
845
  // while a dispatch was in flight) inside the in-flight window so a
@@ -725,6 +870,12 @@ export class ParallAgentGateway {
725
870
  await this.createInputStep(binding.agentSessionId, event);
726
871
  inputStepsCreated = true;
727
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
+ }
728
879
  if (captureText && runtimeEvent.type === 'text' && runtimeEvent.text) {
729
880
  captureText.push(runtimeEvent.text);
730
881
  }
@@ -745,7 +896,7 @@ export class ParallAgentGateway {
745
896
  pendingSendCallIds.delete(runtimeEvent.callId)) {
746
897
  recordMessageSend(sessionKey, !runtimeEvent.error);
747
898
  }
748
- await this.createRuntimeStep(binding.agentSessionId, event, runtimeEvent, stepIdFilePath, contextFilePath);
899
+ await this.createRuntimeStep(binding.agentSessionId, event, runtimeEvent, stepIdFilePath, contextFilePath, laneContextFilePath);
749
900
  }
750
901
  if (!binding) {
751
902
  binding = this.sessionBindings.get(sessionKey);
@@ -768,7 +919,7 @@ export class ParallAgentGateway {
768
919
  await this.createRuntimeStep(binding.agentSessionId, event, {
769
920
  type: 'error',
770
921
  message: `Dispatch failed: ${String(err)}`,
771
- }, stepIdFilePath, contextFilePath);
922
+ }, stepIdFilePath, contextFilePath, laneContextFilePath);
772
923
  }
773
924
  catch (stepErr) {
774
925
  if (this.isSessionNotLiveError(stepErr))
@@ -824,6 +975,9 @@ export class ParallAgentGateway {
824
975
  else if (stepIdFilePath) {
825
976
  this.clearStepIdFile(stepIdFilePath);
826
977
  }
978
+ if (laneContextFilePath) {
979
+ this.updateContextFileStepId(laneContextFilePath, null);
980
+ }
827
981
  this.inFlightDispatches--;
828
982
  if (this.inFlightDispatches === 0 && this.drainResolvers.length > 0) {
829
983
  const resolvers = this.drainResolvers.splice(0);
@@ -889,13 +1043,44 @@ export class ParallAgentGateway {
889
1043
  item.resolve(false);
890
1044
  break;
891
1045
  }
892
- 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
+ }
893
1058
  const events = items.map((item) => item.event);
894
1059
  const last = events[events.length - 1];
895
1060
  const earlier = events.slice(0, -1);
896
1061
  try {
897
1062
  const batchText = [];
898
- 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
+ }
899
1084
  if (!dispatched) {
900
1085
  // Shutdown short-circuit — resolve un-acked so the server requeues
901
1086
  // for the replacement pod and stop draining further items.
@@ -1011,9 +1196,10 @@ export class ParallAgentGateway {
1011
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`);
1012
1197
  break;
1013
1198
  }
1014
- const targetId = this.dispatchState.mainBuffer[0].targetId;
1199
+ const groupKey = this.dispatchGroupKey(this.dispatchState.mainBuffer[0]);
1015
1200
  const events = [];
1016
- while (this.dispatchState.mainBuffer[0]?.targetId === targetId) {
1201
+ while (this.dispatchState.mainBuffer[0] &&
1202
+ this.dispatchGroupKey(this.dispatchState.mainBuffer[0]) === groupKey) {
1017
1203
  events.push(this.dispatchState.mainBuffer.shift());
1018
1204
  }
1019
1205
  const event = events[events.length - 1];
@@ -1024,7 +1210,41 @@ export class ParallAgentGateway {
1024
1210
  : this.dispatchState.pendingForkResults.splice(0);
1025
1211
  const forkPrefix = buildForkResultPrefix(pendingFork);
1026
1212
  this.dispatchState.mainCurrentTargetId = event.targetId;
1213
+ this.mainCurrentGroupKey = groupKey;
1027
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
+ }
1028
1248
  try {
1029
1249
  await this.emitDispatchReceived(event);
1030
1250
  }
@@ -1060,6 +1280,7 @@ export class ParallAgentGateway {
1060
1280
  this.draining = false;
1061
1281
  this.dispatchState.mainDispatching = false;
1062
1282
  this.dispatchState.mainCurrentTargetId = undefined;
1283
+ this.mainCurrentGroupKey = undefined;
1063
1284
  this.dispatchState.mainPreDispatchBranchPoint = undefined;
1064
1285
  if (!this.shuttingDown && this.dispatchState.mainBuffer.length > 0) {
1065
1286
  // Opportunistic re-drain — best-effort, not a recovery deadline, so it
@@ -1088,10 +1309,40 @@ export class ParallAgentGateway {
1088
1309
  const forkPrefix = buildForkResultPrefix(pendingFork);
1089
1310
  this.dispatchState.mainDispatching = true;
1090
1311
  this.dispatchState.mainCurrentTargetId = event.targetId;
1312
+ this.mainCurrentGroupKey = this.dispatchGroupKey(event);
1091
1313
  // Snapshot the on-disk branch point BEFORE runDispatch starts writing
1092
1314
  // to the session file. Fork sessions created while main is in-flight
1093
1315
  // use this to branch from the clean pre-dispatch state.
1094
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
+ }
1095
1346
  try {
1096
1347
  await this.emitDispatchReceived(event);
1097
1348
  }
@@ -1099,6 +1350,7 @@ export class ParallAgentGateway {
1099
1350
  this.opts.log?.warn?.(`mark-received failed, leaving unacked for retry: ${String(err)}`);
1100
1351
  this.dispatchState.mainDispatching = false;
1101
1352
  this.dispatchState.mainCurrentTargetId = undefined;
1353
+ this.mainCurrentGroupKey = undefined;
1102
1354
  this.dispatchState.mainPreDispatchBranchPoint = undefined;
1103
1355
  this.dispatchState.pendingForkResults.unshift(...pendingFork);
1104
1356
  return false;
@@ -1125,7 +1377,20 @@ export class ParallAgentGateway {
1125
1377
  // arrival order is preserved and the event cannot be orphaned in a
1126
1378
  // gap between the steer await and the push.
1127
1379
  this.dispatchState.mainBuffer.push(event);
1128
- 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 &&
1129
1394
  (await this.opts.dispatchAdapter.enqueueDuringDispatch?.(this.opts.runtimeKey, buildEventBody(event)))) {
1130
1395
  this.opts.log?.info(`steer injected for ${event.messageId} (will drain for bookkeeping)`);
1131
1396
  }
@@ -1311,9 +1576,13 @@ export class ParallAgentGateway {
1311
1576
  try {
1312
1577
  const dispatched = await this.handleInboundEvent(event);
1313
1578
  if (dispatched) {
1314
- this.opts.client
1315
- .ackDispatch(this.opts.config.org_id, { source_type: 'message', source_id: data.id })
1316
- .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
+ }
1317
1586
  }
1318
1587
  else {
1319
1588
  this.dispatchedMessages.delete(data.id);
@@ -1324,7 +1593,21 @@ export class ParallAgentGateway {
1324
1593
  this.dispatchedMessages.delete(data.id);
1325
1594
  }
1326
1595
  }
1327
- 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) {
1328
1611
  if (this.shuttingDown)
1329
1612
  return false; // drain window — let server requeue via catch-up
1330
1613
  const dedupeKey = `${task.id}:${task.updated_at}`;
@@ -1354,6 +1637,7 @@ export class ParallAgentGateway {
1354
1637
  sentAt: task.updated_at ?? task.created_at,
1355
1638
  ackSourceType: 'task_activity',
1356
1639
  ackSourceId,
1640
+ dispatchEventId,
1357
1641
  };
1358
1642
  const dispatched = await this.handleInboundEvent(event);
1359
1643
  if (!dispatched) {
@@ -1378,7 +1662,7 @@ export class ParallAgentGateway {
1378
1662
  this.opts.log?.info(`skipping stale task dispatch ${ackSourceId ?? taskId} — assigned to ${task.assignee_id}, creator ${task.creator_id}`);
1379
1663
  return true;
1380
1664
  }
1381
- return this.handleTaskAssignment(task, ackSourceId);
1665
+ return this.handleTaskAssignment(task, ackSourceId, opts.dispatchEventId);
1382
1666
  }
1383
1667
  async handleTaskComment(commentId, taskId, actorId, deliveryReason) {
1384
1668
  if (this.shuttingDown)
@@ -1612,6 +1896,88 @@ export class ParallAgentGateway {
1612
1896
  return true;
1613
1897
  return this.handleExternalTriggerRun(run);
1614
1898
  }
1899
+ // fetchAndHandleChannelMessage resolves a channel_message dispatch to its
1900
+ // durable ChannelMessage + conversation and hands it to the inbound
1901
+ // pipeline. targetId = the ChannelConversation id, so per-conversation
1902
+ // multi-turn continuity rides the same per-target session mechanics as
1903
+ // chats. Design: docs/engineering-design/external-im-channel-design.md.
1904
+ async fetchAndHandleChannelMessage(messageId) {
1905
+ if (this.shuttingDown)
1906
+ return false;
1907
+ // Capped dedupe (the chat-message path, not the unbounded task set): a busy
1908
+ // external IM conversation would otherwise retain one key per message ever
1909
+ // handled on a long-lived agent. On a RETRYABLE failure the claim is
1910
+ // released so a later dispatch.new / catch-up re-fetches (matching the
1911
+ // chat-message path); a 404/stale result keeps the claim and acks.
1912
+ const claimKey = `channel_message:${messageId}`;
1913
+ if (!this.tryClaimMessage(claimKey))
1914
+ return false;
1915
+ let msg = null;
1916
+ let conv = null;
1917
+ try {
1918
+ msg = await this.opts.client.getChannelMessage(this.opts.config.org_id, messageId);
1919
+ conv = await this.opts.client.getChannelConversation(this.opts.config.org_id, msg.conversation_id);
1920
+ }
1921
+ catch (err) {
1922
+ const status = err?.status;
1923
+ if (status === 404) {
1924
+ this.opts.log?.warn(`channel message ${messageId} not accessible (404), acking stale dispatch`);
1925
+ return true;
1926
+ }
1927
+ this.dispatchedMessages.delete(claimKey);
1928
+ this.opts.log?.warn(`channel message fetch failed for ${messageId}, leaving pending: ${String(err)}`);
1929
+ return false;
1930
+ }
1931
+ if (!msg || !conv) {
1932
+ return true;
1933
+ }
1934
+ this.opts.log?.info(`channel message: ${msg.id} (conversation ${conv.id})`);
1935
+ // Resolve the provider from the conversation's connection for prompt
1936
+ // labeling + the reply-clip hint. The connection id → provider mapping
1937
+ // is stable, so a tiny cache avoids one fetch per message.
1938
+ let provider = this.channelConnectionProviders.get(conv.connection_id);
1939
+ if (!provider) {
1940
+ try {
1941
+ const connection = await this.opts.client.getChannelConnection(this.opts.config.org_id, conv.connection_id);
1942
+ provider = connection.provider;
1943
+ this.channelConnectionProviders.set(conv.connection_id, provider);
1944
+ }
1945
+ catch {
1946
+ provider = undefined; // label degrades; reply hint still names the clip generically
1947
+ }
1948
+ }
1949
+ const event = {
1950
+ type: 'channel_message',
1951
+ targetId: conv.id,
1952
+ targetName: conv.external_user_name || conv.external_conversation_id,
1953
+ targetType: 'channel_conversation',
1954
+ senderId: msg.external_user_id || 'external',
1955
+ senderName: msg.external_user_name || msg.external_user_id || 'external user',
1956
+ messageId: msg.id,
1957
+ body: msg.text,
1958
+ sentAt: msg.received_at,
1959
+ channelProvider: provider,
1960
+ channelConversationType: conv.conversation_type || undefined,
1961
+ channelExternalConversationId: conv.external_conversation_id,
1962
+ channelExternalMessageId: msg.external_message_id,
1963
+ ackSourceType: 'channel_message',
1964
+ ackSourceId: msg.id,
1965
+ };
1966
+ // Release the claim if the event isn't actually dispatched (or throws) so
1967
+ // a retry can re-attempt — same contract as the chat-message path.
1968
+ let dispatched;
1969
+ try {
1970
+ dispatched = await this.handleInboundEvent(event);
1971
+ }
1972
+ catch (err) {
1973
+ this.dispatchedMessages.delete(claimKey);
1974
+ throw err;
1975
+ }
1976
+ if (!dispatched) {
1977
+ this.dispatchedMessages.delete(claimKey);
1978
+ }
1979
+ return dispatched;
1980
+ }
1615
1981
  async handleExternalTriggerRun(run) {
1616
1982
  if (this.shuttingDown)
1617
1983
  return false;
@@ -1749,10 +2115,14 @@ export class ParallAgentGateway {
1749
2115
  }
1750
2116
  processed++;
1751
2117
  try {
1752
- let dispatched = false;
2118
+ const ackItem = () => {
2119
+ this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
2120
+ };
1753
2121
  if (item.event_type === 'task_assign' && item.task_id) {
1754
2122
  try {
1755
- 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);
1756
2126
  }
1757
2127
  catch (err) {
1758
2128
  this.opts.log?.warn(`catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
@@ -1761,7 +2131,10 @@ export class ParallAgentGateway {
1761
2131
  }
1762
2132
  else if (item.event_type === 'task_update' && item.task_id) {
1763
2133
  try {
1764
- 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);
1765
2138
  }
1766
2139
  catch (err) {
1767
2140
  this.opts.log?.warn(`catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
@@ -1769,61 +2142,29 @@ export class ParallAgentGateway {
1769
2142
  }
1770
2143
  }
1771
2144
  else if (item.event_type === 'task_comment' && item.source_id && item.task_id) {
1772
- 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);
1773
2146
  }
1774
2147
  else if (item.event_type === 'wiki_comment' && item.source_id) {
1775
- 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);
1776
2149
  }
1777
2150
  else if (item.event_type === 'schedule.fire' && item.source_id) {
1778
- 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);
1779
2152
  }
1780
2153
  else if (item.event_type === 'external_trigger' && item.source_id) {
1781
- dispatched = await this.fetchAndHandleExternalTriggerRun(item.source_id);
2154
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleExternalTriggerRun(item.source_id), ackItem);
2155
+ }
2156
+ else if (item.event_type === 'channel_message' && item.source_id) {
2157
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleChannelMessage(item.source_id), ackItem);
1782
2158
  }
1783
2159
  else if (item.event_type === 'approval_decided' && item.source_id) {
1784
- 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);
1785
2161
  }
1786
2162
  else if (item.event_type === 'message' && item.source_id && item.chat_id) {
1787
- if (!this.tryClaimMessage(item.source_id))
1788
- continue;
1789
- let msg = null;
1790
- let msgFetchFailed = false;
1791
- try {
1792
- msg = await this.opts.client.getMessage(item.source_id);
1793
- }
1794
- catch (err) {
1795
- const status = err?.status;
1796
- if (status === 404) {
1797
- msg = null;
1798
- }
1799
- else {
1800
- msgFetchFailed = true;
1801
- this.opts.log?.warn(`catch-up message fetch failed for ${item.source_id}, leaving pending: ${String(err)}`);
1802
- }
1803
- }
1804
- if (msgFetchFailed) {
1805
- this.dispatchedMessages.delete(item.source_id);
1806
- continue;
1807
- }
1808
- if (!msg || msg.sender_id === this.opts.agentUserId) {
1809
- this.dispatchedMessages.delete(item.source_id);
1810
- this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
1811
- continue;
1812
- }
1813
- const decision = await this.buildMessageDispatchDecision(item.chat_id, msg);
1814
- if (decision.action === 'retry') {
1815
- this.dispatchedMessages.delete(item.source_id);
1816
- continue;
1817
- }
1818
- if (decision.action === 'skip') {
1819
- this.dispatchedMessages.delete(item.source_id);
1820
- this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
1821
- continue;
1822
- }
1823
- dispatched = await this.handleInboundEvent(decision.event);
1824
- }
1825
- if (dispatched) {
1826
- 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
+ });
1827
2168
  }
1828
2169
  }
1829
2170
  catch (err) {
@@ -1892,6 +2233,20 @@ export class ParallAgentGateway {
1892
2233
  this.abortFork(targetId, 'ws reconnect');
1893
2234
  }
1894
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
+ }
1895
2250
  const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
1896
2251
  try {
1897
2252
  const count = await fetchAllChats(client, config.org_id, this.chatInfoMap);
@@ -1971,6 +2326,13 @@ export class ParallAgentGateway {
1971
2326
  }
1972
2327
  if (this.heartbeatTimer)
1973
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
+ }
1974
2336
  await this.opts.onBeforeDisconnect?.();
1975
2337
  this.opts.ws.disconnect();
1976
2338
  this.opts.log?.info(`disconnected`);