@parall/agent-core 1.43.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@ 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
6
  import { buildErrorStepContent, } from './dispatch-adapter.js';
7
- import { consumeMessageWorkItem, consumeTypedDispatch, dispatchLaneGroup, } from './gateway-lane-flow.js';
7
+ import { consumeMessageWorkItem, consumeTypedDispatch, dispatchLaneGroup, resolveDispatchByID, settleDrainedTypedGroup, typedLedgerEventIds, } from './gateway-lane-flow.js';
8
8
  import { LaneLedger } from './lane-ledger.js';
9
9
  import { routeTrigger } from './routing.js';
10
10
  import { clearDispatchMessageId, clearDispatchMetrics, clearDispatchNoReply, clearSessionMessageId, getDispatchMetrics, recordDeliverText, recordMessageSend, recordNoReply, recordToolCall, resetDispatchMetrics, setDispatchMessageId, setDispatchNoReply, setSessionChatId, setSessionMessageId, } from './session-state.js';
@@ -256,25 +256,28 @@ export class ParallAgentGateway {
256
256
  // level claim/ack would consume or clear the wrong sibling.
257
257
  await this.consumeTypedDispatch(data.dispatch_event_id
258
258
  ? { dispatchEventId: data.dispatch_event_id }
259
- : { sourceType: 'task_activity', sourceId: data.id }, (dispatchEventId) => this.handleTaskAssignment(data, data.id, dispatchEventId), (dispatchEventId) => {
260
- if (dispatchEventId) {
261
- return this.ackDispatchEvent(dispatchEventId, () => {
259
+ : { sourceType: 'task_activity', sourceId: data.id }, (dispatchEventId) => this.handleTaskAssignment(data, data.id, dispatchEventId), {
260
+ legacyAck: (dispatchEventId) => {
261
+ if (dispatchEventId) {
262
+ return this.ackDispatchEvent(dispatchEventId, () => {
263
+ this.dispatchedTasks.delete(`${data.id}:${data.updated_at}`);
264
+ });
265
+ }
266
+ return this.opts.client
267
+ .ackDispatch(this.opts.config.org_id, {
268
+ source_type: 'task_activity',
269
+ source_id: data.id,
270
+ })
271
+ .then(() => true, (err) => {
272
+ // Same contract as ackDispatchEvent: a failed ack is a
273
+ // failed consume (arms backoff) and must free the hot-path
274
+ // dedupe so the re-drive isn't rejected by this pod forever.
262
275
  this.dispatchedTasks.delete(`${data.id}:${data.updated_at}`);
276
+ this.opts.log?.warn(`dispatch ack failed for task ${data.id}, releasing for re-drive: ${String(err)}`);
277
+ return false;
263
278
  });
264
- }
265
- return this.opts.client
266
- .ackDispatch(this.opts.config.org_id, {
267
- source_type: 'task_activity',
268
- source_id: data.id,
269
- })
270
- .then(() => true, (err) => {
271
- // Same contract as ackDispatchEvent: a failed ack is a
272
- // failed consume (arms backoff) and must free the hot-path
273
- // dedupe so the re-drive isn't rejected by this pod forever.
274
- this.dispatchedTasks.delete(`${data.id}:${data.updated_at}`);
275
- this.opts.log?.warn(`dispatch ack failed for task ${data.id}, releasing for re-drive: ${String(err)}`);
276
- return false;
277
- });
279
+ },
280
+ clearDedupe: () => this.dispatchedTasks.delete(`${data.id}:${data.updated_at}`),
278
281
  });
279
282
  }
280
283
  catch (err) {
@@ -296,7 +299,10 @@ export class ParallAgentGateway {
296
299
  if (!data.source_id || !data.task_id)
297
300
  return;
298
301
  try {
299
- await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.handleTaskComment(data.source_id, data.task_id ?? '', data.actor_id, data.delivery_reason), () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)));
302
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, (dispatchEventId) => this.handleTaskComment(data.source_id, data.task_id ?? '', data.actor_id, data.delivery_reason, dispatchEventId), {
303
+ legacyAck: () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
304
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
305
+ });
300
306
  }
301
307
  catch (err) {
302
308
  this.opts.log?.error(`task comment dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -306,7 +312,10 @@ export class ParallAgentGateway {
306
312
  if (!data.source_id)
307
313
  return;
308
314
  try {
309
- await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.handleWikiComment(data.source_id, data.actor_id, data.delivery_reason), () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)));
315
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, (dispatchEventId) => this.handleWikiComment(data.source_id, data.actor_id, data.delivery_reason, dispatchEventId), {
316
+ legacyAck: () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
317
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
318
+ });
310
319
  }
311
320
  catch (err) {
312
321
  this.opts.log?.error(`wiki comment dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -319,7 +328,10 @@ export class ParallAgentGateway {
319
328
  await this.consumeTypedDispatch({ dispatchEventId: data.id }, (dispatchEventId) => this.handleTaskDispatch(data.task_id ?? '', data.source_id ?? data.task_id ?? '', {
320
329
  allowCreator: true,
321
330
  dispatchEventId,
322
- }), () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)));
331
+ }), {
332
+ legacyAck: () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
333
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
334
+ });
323
335
  }
324
336
  catch (err) {
325
337
  this.opts.log?.error(`task update dispatch failed for ${data.task_id}: ${String(err)}`);
@@ -329,7 +341,10 @@ export class ParallAgentGateway {
329
341
  if (!data.source_id)
330
342
  return;
331
343
  try {
332
- await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleScheduleFire(data.source_id, data.actor_id), () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)));
344
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, (dispatchEventId) => this.fetchAndHandleScheduleFire(data.source_id, data.actor_id, dispatchEventId), {
345
+ legacyAck: () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
346
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
347
+ });
333
348
  }
334
349
  catch (err) {
335
350
  this.opts.log?.error(`schedule fire dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -339,7 +354,10 @@ export class ParallAgentGateway {
339
354
  if (!data.source_id)
340
355
  return;
341
356
  try {
342
- await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleExternalTriggerRun(data.source_id), () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)));
357
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, (dispatchEventId) => this.fetchAndHandleExternalTriggerRun(data.source_id, dispatchEventId), {
358
+ legacyAck: () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
359
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
360
+ });
343
361
  }
344
362
  catch (err) {
345
363
  this.opts.log?.error(`external trigger dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -349,7 +367,10 @@ export class ParallAgentGateway {
349
367
  if (!data.source_id)
350
368
  return;
351
369
  try {
352
- await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleChannelMessage(data.source_id), () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)));
370
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, (dispatchEventId) => this.fetchAndHandleChannelMessage(data.source_id, dispatchEventId), {
371
+ legacyAck: () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
372
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
373
+ });
353
374
  }
354
375
  catch (err) {
355
376
  this.opts.log?.error(`channel message dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -359,7 +380,10 @@ export class ParallAgentGateway {
359
380
  if (!data.source_id)
360
381
  return;
361
382
  try {
362
- await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleApprovalDecided(data.source_id, data.actor_id, data.chat_id ?? null), () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)));
383
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, (dispatchEventId) => this.fetchAndHandleApprovalDecided(data.source_id, data.actor_id, data.chat_id ?? null, dispatchEventId), {
384
+ legacyAck: () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
385
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
386
+ });
363
387
  }
364
388
  catch (err) {
365
389
  this.opts.log?.error(`approval decided dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -410,6 +434,44 @@ export class ParallAgentGateway {
410
434
  this.dispatchedMessages.add(id);
411
435
  return true;
412
436
  }
437
+ /**
438
+ * Lane currently being dispatched per session — lets external activity
439
+ * signals renew exactly the caller's lane (renewing all lanes would keep
440
+ * an unrelated stalled fork's lane leased forever).
441
+ */
442
+ sessionActiveLanes = new Map();
443
+ noteSessionLane(sessionKey, laneKey) {
444
+ if (laneKey == null)
445
+ this.sessionActiveLanes.delete(sessionKey);
446
+ else
447
+ this.sessionActiveLanes.set(sessionKey, laneKey);
448
+ }
449
+ /**
450
+ * External runtime-activity signal for adapters whose tool activity does
451
+ * not flow through the RuntimeEvent stream (openclaw hooks call this from
452
+ * the tool-call lifecycle): renews the session's OWN active ledger lane so
453
+ * a long tool call cannot outlive the lease and get dethroned mid-turn.
454
+ * No-op without an active ledger lane for the session.
455
+ */
456
+ touchRuntimeActivity(sessionKey) {
457
+ if (this.ledgerDisabled)
458
+ return;
459
+ const laneKey = this.sessionActiveLanes.get(sessionKey);
460
+ if (laneKey)
461
+ this.laneLedger?.renewByKey(laneKey);
462
+ }
463
+ /** Sessions whose in-flight turn surfaced a runtime error event. */
464
+ turnErrorSessions = new Set();
465
+ /**
466
+ * Consume (read-and-clear) the error marker for sessionKey's last turn.
467
+ * Feeds complete's turn_outcome so an error turn's lane members are
468
+ * released for retry instead of no_action-swept (design §3). Consuming
469
+ * (rather than peeking) keeps one-shot fork session keys from accumulating
470
+ * in the set forever.
471
+ */
472
+ consumeTurnError(sessionKey) {
473
+ return this.turnErrorSessions.delete(sessionKey);
474
+ }
413
475
  async emitDispatchReceived(event) {
414
476
  const sourceType = event.ackSourceType ?? (event.type === 'task' ? 'task_activity' : 'message');
415
477
  const sourceId = event.ackSourceId ?? event.messageId;
@@ -418,6 +480,20 @@ export class ParallAgentGateway {
418
480
  source_id: sourceId,
419
481
  });
420
482
  }
483
+ /**
484
+ * Sticky: the server answered a by-id complete with 400 (predates the
485
+ * form). Typed resolution falls back to the legacy ack for the rest of
486
+ * the process lifetime.
487
+ */
488
+ typedByIdCompleteUnsupported = false;
489
+ // Typed-face ledger helpers live in gateway-lane-flow.ts; thin delegates
490
+ // keep the site code and tests on the class surface.
491
+ typedLedgerEventIds(events) {
492
+ return typedLedgerEventIds(this.laneFlowHost(), events);
493
+ }
494
+ resolveDispatchByID(dispatchEventId, lane) {
495
+ return resolveDispatchByID(this.laneFlowHost(), dispatchEventId, lane);
496
+ }
421
497
  /** True when this event's lifecycle is owned by the dispatch lane ledger. */
422
498
  usesLaneLedger(event) {
423
499
  return this.laneLedger != null && !this.ledgerDisabled && this.laneLedger.handles(event);
@@ -452,15 +528,16 @@ export class ParallAgentGateway {
452
528
  dispatchLaneGroup(opts) {
453
529
  return dispatchLaneGroup(this.laneFlowHost(), opts);
454
530
  }
455
- consumeTypedDispatch(ref, run, ack) {
456
- return consumeTypedDispatch(this.laneFlowHost(), ref, run, ack);
531
+ consumeTypedDispatch(ref, run, hooks) {
532
+ return consumeTypedDispatch(this.laneFlowHost(), ref, run, hooks);
457
533
  }
458
- // Typed completion must wait until the administrative ack has either
459
- // committed or failed. Errors stay best-effort: a failed ack leaves the row
460
- // received, so Complete releases and re-drives it safely. The boolean
461
- // outcome feeds the typed-consume backoff an ack that failed must count
462
- // as a failed consume, or an ack outage would clear the backoff entry and
463
- // let the release re-drive spin at wire speed.
534
+ // Legacy administrative ack (ledger-disabled fallback only). Typed
535
+ // completion must wait until the ack has either committed or failed.
536
+ // Errors stay best-effort: a failed ack leaves the row received, so
537
+ // Complete releases and re-drives it safely. The boolean outcome feeds the
538
+ // typed-consume backoff an ack that failed must count as a failed
539
+ // consume, or an ack outage would clear the backoff entry and let the
540
+ // release re-drive spin at wire speed.
464
541
  ackDispatchEvent(dispatchEventId, onFailure) {
465
542
  return this.opts.client.ackDispatchByID(this.opts.config.org_id, dispatchEventId).then(() => true, (err) => {
466
543
  // Complete will return the still-received item to pending and publish
@@ -471,6 +548,9 @@ export class ParallAgentGateway {
471
548
  return false;
472
549
  });
473
550
  }
551
+ // PARITY: this switch and gateway-lane-flow's clearTypedDedupeForEvent must
552
+ // handle the same typed source families — extend BOTH when adding a typed
553
+ // event type (same dedupe entries, keyed from different event shapes).
474
554
  clearTypedDispatchDedupe(item) {
475
555
  switch (item.event_type) {
476
556
  case 'task_assign':
@@ -814,6 +894,7 @@ export class ParallAgentGateway {
814
894
  this.pendingRestartNotification = null;
815
895
  }
816
896
  resetDispatchMetrics(sessionKey);
897
+ this.turnErrorSessions.delete(sessionKey);
817
898
  return runWithSessionKey(sessionKey, async () => {
818
899
  let dispatchSpan = null;
819
900
  setSessionChatId(sessionKey, event.targetId);
@@ -950,6 +1031,9 @@ export class ParallAgentGateway {
950
1031
  pendingSendCallIds.delete(runtimeEvent.callId)) {
951
1032
  recordMessageSend(sessionKey, !runtimeEvent.error);
952
1033
  }
1034
+ if (runtimeEvent.type === 'error') {
1035
+ this.turnErrorSessions.add(sessionKey);
1036
+ }
953
1037
  await this.createRuntimeStep(binding.agentSessionId, event, runtimeEvent, stepIdFilePath, contextFilePath, laneContextFilePath);
954
1038
  }
955
1039
  if (!binding) {
@@ -1122,7 +1206,12 @@ export class ParallAgentGateway {
1122
1206
  body: buildForkScopePrefix(last) + buildEventBody(last),
1123
1207
  earlier,
1124
1208
  captureText: batchText,
1125
- hasMoreLocal: () => fork.queue.length > 0,
1209
+ // Per-LANE residue check (parity with the main-buffer path):
1210
+ // the fork queue can hold several lanes (channel + thread of
1211
+ // the same chat). A whole-queue check would defer THIS lane's
1212
+ // complete behind another lane's items and never revisit it —
1213
+ // its members would sit received until lease expiry.
1214
+ hasMoreLocal: () => fork.queue.some((it) => this.dispatchGroupKey(it.event) === this.dispatchGroupKey(last)),
1126
1215
  });
1127
1216
  if (outcome === 'foreign') {
1128
1217
  // Another pod owns the lane — the events stay pending server-side.
@@ -1130,10 +1219,32 @@ export class ParallAgentGateway {
1130
1219
  item.resolve(false);
1131
1220
  break;
1132
1221
  }
1222
+ if (outcome === 'failed') {
1223
+ // Error turn: complete(error) already released the members for
1224
+ // redelivery. Do NOT push them to processedEvents — the redrive
1225
+ // must not arrive wearing an "already handled" prefix. Keep
1226
+ // draining; the released work rejoins the next claim.
1227
+ for (const item of items)
1228
+ item.resolve(false);
1229
+ continue;
1230
+ }
1133
1231
  dispatched = outcome === 'dispatched';
1134
1232
  }
1135
1233
  else {
1136
1234
  dispatched = await this.runDispatch(last, fork.fork.sessionKey, buildForkScopePrefix(last) + buildEventBody(last), earlier, batchText);
1235
+ if (dispatched &&
1236
+ this.typedLedgerEventIds(events) &&
1237
+ this.consumeTurnError(fork.fork.sessionKey)) {
1238
+ // Typed error turn in a fork: report failure to the awaiting
1239
+ // typed consumes so the members release for a budgeted retry,
1240
+ // and do NOT record the events as handled — the redrive must
1241
+ // not arrive wearing an "already handled" prefix. Keep draining
1242
+ // (an error turn is not a shutdown).
1243
+ this.opts.log?.info(`typed fork turn for ${last.messageId} surfaced a runtime error — releasing for retry`);
1244
+ for (const item of items)
1245
+ item.resolve(false);
1246
+ continue;
1247
+ }
1137
1248
  }
1138
1249
  if (!dispatched) {
1139
1250
  // Shutdown short-circuit — resolve un-acked so the server requeues
@@ -1168,6 +1279,10 @@ export class ParallAgentGateway {
1168
1279
  }
1169
1280
  }
1170
1281
  finally {
1282
+ // One-shot fork keys are never dispatched again — drop any error marker
1283
+ // the legacy (non-ledger) path left unconsumed, or the set grows with
1284
+ // every failed ephemeral fork.
1285
+ this.turnErrorSessions.delete(fork.fork.sessionKey);
1171
1286
  if (fork.deadlineTimer) {
1172
1287
  clearTimeout(fork.deadlineTimer);
1173
1288
  fork.deadlineTimer = null;
@@ -1295,18 +1410,32 @@ export class ParallAgentGateway {
1295
1410
  this.dispatchState.pendingForkResults.unshift(...pendingFork);
1296
1411
  continue;
1297
1412
  }
1413
+ if (outcome === 'failed') {
1414
+ // Error turn settled with complete(error) — members released for
1415
+ // redelivery. The fork results were not consumed by the failed
1416
+ // turn; keep them for the next real dispatch.
1417
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1418
+ continue;
1419
+ }
1298
1420
  // Dispatched — resolution happened server-side (reply cover or
1299
1421
  // no_action sweep); no legacy acks.
1300
1422
  continue;
1301
1423
  }
1302
- try {
1303
- await this.emitDispatchReceived(event);
1304
- }
1305
- catch (err) {
1306
- this.opts.log?.warn?.(`mark-received failed for buffered dispatch, leaving unacked for retry: ${String(err)}`);
1307
- this.dispatchState.mainBuffer.unshift(...events);
1308
- this.dispatchState.pendingForkResults.unshift(...pendingFork);
1309
- break;
1424
+ // Buffered typed events are the one wrapper-less dispatch path: their
1425
+ // consumeTypedDispatch guard returned long ago (buffer-main resolves
1426
+ // false) and released the claims, so THIS site owns their resolution.
1427
+ // Message groups here are the ledger-disabled legacy flow.
1428
+ const typedRefs = this.typedLedgerEventIds(events);
1429
+ if (!typedRefs) {
1430
+ try {
1431
+ await this.emitDispatchReceived(event);
1432
+ }
1433
+ catch (err) {
1434
+ this.opts.log?.warn?.(`mark-received failed for buffered dispatch, leaving unacked for retry: ${String(err)}`);
1435
+ this.dispatchState.mainBuffer.unshift(...events);
1436
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1437
+ break;
1438
+ }
1310
1439
  }
1311
1440
  const dispatched = await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event), earlier);
1312
1441
  if (!dispatched) {
@@ -1317,6 +1446,13 @@ export class ParallAgentGateway {
1317
1446
  this.dispatchState.pendingForkResults.unshift(...pendingFork);
1318
1447
  break;
1319
1448
  }
1449
+ if (typedRefs) {
1450
+ // Wrapper-less resolution of the buffered typed group — protocol
1451
+ // lives in gateway-lane-flow.ts. The turn-error marker is consumed
1452
+ // HERE, before this loop can start another turn on the session.
1453
+ await settleDrainedTypedGroup(this.laneFlowHost(), events, typedRefs, this.consumeTurnError(this.opts.runtimeKey));
1454
+ continue;
1455
+ }
1320
1456
  for (const bufferedEvent of events) {
1321
1457
  const sourceType = bufferedEvent.ackSourceType ??
1322
1458
  (bufferedEvent.type === 'task' ? 'task_activity' : 'message');
@@ -1397,21 +1533,36 @@ export class ParallAgentGateway {
1397
1533
  }
1398
1534
  return outcome === 'dispatched';
1399
1535
  }
1400
- try {
1401
- await this.emitDispatchReceived(event);
1402
- }
1403
- catch (err) {
1404
- this.opts.log?.warn?.(`mark-received failed, leaving unacked for retry: ${String(err)}`);
1405
- this.dispatchState.mainDispatching = false;
1406
- this.dispatchState.mainCurrentTargetId = undefined;
1407
- this.mainCurrentGroupKey = undefined;
1408
- this.dispatchState.mainPreDispatchBranchPoint = undefined;
1409
- this.dispatchState.pendingForkResults.unshift(...pendingFork);
1410
- return false;
1536
+ // Ledger-claimed typed events skip the legacy received write — the
1537
+ // claim already marked the row received, and a second mark here would
1538
+ // wipe its lease owner (the 0713 black-hole shape on the typed face).
1539
+ const typedRefs = this.typedLedgerEventIds([event]);
1540
+ if (!typedRefs) {
1541
+ try {
1542
+ await this.emitDispatchReceived(event);
1543
+ }
1544
+ catch (err) {
1545
+ this.opts.log?.warn?.(`mark-received failed, leaving unacked for retry: ${String(err)}`);
1546
+ this.dispatchState.mainDispatching = false;
1547
+ this.dispatchState.mainCurrentTargetId = undefined;
1548
+ this.mainCurrentGroupKey = undefined;
1549
+ this.dispatchState.mainPreDispatchBranchPoint = undefined;
1550
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1551
+ return false;
1552
+ }
1411
1553
  }
1412
1554
  let dispatched = false;
1413
1555
  try {
1414
1556
  dispatched = await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event));
1557
+ if (dispatched && typedRefs && this.consumeTurnError(this.opts.runtimeKey)) {
1558
+ // Typed error turn: report failure to the enclosing typed consume
1559
+ // so the member releases for a budgeted retry instead of being
1560
+ // terminally resolved (tech-debt: typed-dispatch-error-outcome).
1561
+ // Consumed HERE, before the finally's drain can start another
1562
+ // turn on this session and clear the marker.
1563
+ this.opts.log?.info(`typed dispatch turn for ${event.messageId} surfaced a runtime error — releasing for retry`);
1564
+ dispatched = false;
1565
+ }
1415
1566
  if (!dispatched) {
1416
1567
  // Shutdown short-circuit — restore the fork results so a future
1417
1568
  // pod can replay them, and return false so handleMessage skips ack.
@@ -1438,9 +1589,16 @@ export class ParallAgentGateway {
1438
1589
  // fold leaves the event buffered — the drain claims it as its own
1439
1590
  // turn. Injection requires an exact lane match (same chat AND same
1440
1591
  // thread) — a thread message never rides a channel turn.
1592
+ // The adapter-capability check comes BEFORE the fold: on a
1593
+ // buffer-only adapter (openclaw — no enqueueDuringDispatch) a
1594
+ // folded-but-never-injected member would be broad-covered by the
1595
+ // current turn's reply without the model ever seeing it. Leaving
1596
+ // the event un-folded keeps it buffered; the drain claims it as
1597
+ // its own turn and folds it there.
1441
1598
  if (this.mainCurrentGroupKey === this.dispatchGroupKey(event) &&
1599
+ this.opts.dispatchAdapter.enqueueDuringDispatch != null &&
1442
1600
  (await this.laneLedger?.steerLive(event)) &&
1443
- (await this.opts.dispatchAdapter.enqueueDuringDispatch?.(this.opts.runtimeKey, buildEventBody(event)))) {
1601
+ (await this.opts.dispatchAdapter.enqueueDuringDispatch(this.opts.runtimeKey, buildEventBody(event)))) {
1444
1602
  this.opts.log?.info(`steer folded+injected for ${event.messageId} (will drain for bookkeeping)`);
1445
1603
  }
1446
1604
  }
@@ -1474,13 +1632,21 @@ export class ParallAgentGateway {
1474
1632
  this.dispatchState.mainBuffer.push(event);
1475
1633
  return false;
1476
1634
  }
1477
- try {
1478
- await this.emitDispatchReceived(event);
1479
- }
1480
- catch (err) {
1481
- this.opts.log?.warn?.(`mark-received failed for fork dispatch, leaving unacked for retry: ${String(err)}`);
1482
- this.dispatchState.mainBuffer.push(event);
1483
- return false;
1635
+ // Ledger-managed events must never touch the legacy received surface
1636
+ // — the fork's lane claim marks them received. A mark-received here
1637
+ // outruns the claim, strands the row ownerless, and the chat goes
1638
+ // silent (the 0713 black hole; dispatch-convergence-design.md §6).
1639
+ // Same rule for ledger-claimed typed events: their dsp-lane claim
1640
+ // owns received-ness, and a re-mark would wipe the lease owner.
1641
+ if (!this.usesLaneLedger(event) && !this.typedLedgerEventIds([event])) {
1642
+ try {
1643
+ await this.emitDispatchReceived(event);
1644
+ }
1645
+ catch (err) {
1646
+ this.opts.log?.warn?.(`mark-received failed for fork dispatch, leaving unacked for retry: ${String(err)}`);
1647
+ this.dispatchState.mainBuffer.push(event);
1648
+ return false;
1649
+ }
1484
1650
  }
1485
1651
  const fork = await this.opts.dispatchAdapter.forkSession({
1486
1652
  sessionKey: this.opts.runtimeKey,
@@ -1667,7 +1833,10 @@ export class ParallAgentGateway {
1667
1833
  return;
1668
1834
  await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.handleTaskDispatch(item.task_id ?? '', item.source_id ?? item.task_id ?? '', {
1669
1835
  dispatchEventId,
1670
- }), (dispatchEventId) => this.ackDispatchEvent(dispatchEventId ?? item.id, () => this.clearTypedDispatchDedupe(item)));
1836
+ }), {
1837
+ legacyAck: (dispatchEventId) => this.ackDispatchEvent(dispatchEventId ?? item.id, () => this.clearTypedDispatchDedupe(item)),
1838
+ clearDedupe: () => this.clearTypedDispatchDedupe(item),
1839
+ });
1671
1840
  }
1672
1841
  consumeMessageWorkItem(item) {
1673
1842
  return consumeMessageWorkItem(this.laneFlowHost(), item);
@@ -1729,7 +1898,7 @@ export class ParallAgentGateway {
1729
1898
  }
1730
1899
  return this.handleTaskAssignment(task, ackSourceId, opts.dispatchEventId);
1731
1900
  }
1732
- async handleTaskComment(commentId, taskId, actorId, deliveryReason) {
1901
+ async handleTaskComment(commentId, taskId, actorId, deliveryReason, dispatchEventId) {
1733
1902
  if (this.shuttingDown)
1734
1903
  return false; // drain window — let server requeue via catch-up
1735
1904
  const dedupeKey = `comment:${commentId}`;
@@ -1791,6 +1960,7 @@ export class ParallAgentGateway {
1791
1960
  deliveryReason: deliveryReason ?? undefined,
1792
1961
  ackSourceType: 'comment',
1793
1962
  ackSourceId: commentId,
1963
+ dispatchEventId,
1794
1964
  };
1795
1965
  let dispatched;
1796
1966
  try {
@@ -1806,7 +1976,7 @@ export class ParallAgentGateway {
1806
1976
  }
1807
1977
  return dispatched;
1808
1978
  }
1809
- async handleWikiComment(commentId, actorId, deliveryReason) {
1979
+ async handleWikiComment(commentId, actorId, deliveryReason, dispatchEventId) {
1810
1980
  if (this.shuttingDown)
1811
1981
  return false; // drain window — let server requeue via catch-up
1812
1982
  // Shares the comment dedupe namespace with handleTaskComment; comment IDs
@@ -1859,6 +2029,7 @@ export class ParallAgentGateway {
1859
2029
  replyTargetUri: comment.target_uri,
1860
2030
  ackSourceType: 'comment',
1861
2031
  ackSourceId: commentId,
2032
+ dispatchEventId,
1862
2033
  };
1863
2034
  let dispatched;
1864
2035
  try {
@@ -1882,7 +2053,7 @@ export class ParallAgentGateway {
1882
2053
  * access to already-delivered run snapshots, and the runtime must not crash
1883
2054
  * or retry forever in that case.
1884
2055
  */
1885
- async fetchAndHandleScheduleFire(runId, actorId) {
2056
+ async fetchAndHandleScheduleFire(runId, actorId, dispatchEventId) {
1886
2057
  let run = null;
1887
2058
  try {
1888
2059
  run = await this.opts.client.getScheduleRun(this.opts.config.org_id, runId);
@@ -1903,9 +2074,9 @@ export class ParallAgentGateway {
1903
2074
  }
1904
2075
  if (!run)
1905
2076
  return true;
1906
- return this.handleScheduleFire(run, actorId);
2077
+ return this.handleScheduleFire(run, actorId, dispatchEventId);
1907
2078
  }
1908
- async handleScheduleFire(run, actorId) {
2079
+ async handleScheduleFire(run, actorId, dispatchEventId) {
1909
2080
  if (this.shuttingDown)
1910
2081
  return false; // drain window — let server requeue via catch-up
1911
2082
  const dedupeKey = `schedule_run:${run.id}`;
@@ -1929,6 +2100,7 @@ export class ParallAgentGateway {
1929
2100
  attachedUri: run.fired_attached_uri ?? undefined,
1930
2101
  ackSourceType: 'schedule_run',
1931
2102
  ackSourceId: run.id,
2103
+ dispatchEventId,
1932
2104
  };
1933
2105
  let dispatched;
1934
2106
  try {
@@ -1943,7 +2115,7 @@ export class ParallAgentGateway {
1943
2115
  }
1944
2116
  return dispatched;
1945
2117
  }
1946
- async fetchAndHandleExternalTriggerRun(runId) {
2118
+ async fetchAndHandleExternalTriggerRun(runId, dispatchEventId) {
1947
2119
  let run = null;
1948
2120
  try {
1949
2121
  run = await this.opts.client.getExternalTriggerRun(this.opts.config.org_id, runId);
@@ -1959,14 +2131,14 @@ export class ParallAgentGateway {
1959
2131
  }
1960
2132
  if (!run)
1961
2133
  return true;
1962
- return this.handleExternalTriggerRun(run);
2134
+ return this.handleExternalTriggerRun(run, dispatchEventId);
1963
2135
  }
1964
2136
  // fetchAndHandleChannelMessage resolves a channel_message dispatch to its
1965
2137
  // durable ChannelMessage + conversation and hands it to the inbound
1966
2138
  // pipeline. targetId = the ChannelConversation id, so per-conversation
1967
2139
  // multi-turn continuity rides the same per-target session mechanics as
1968
2140
  // chats. Design: docs/engineering-design/external-im-channel-design.md.
1969
- async fetchAndHandleChannelMessage(messageId) {
2141
+ async fetchAndHandleChannelMessage(messageId, dispatchEventId) {
1970
2142
  if (this.shuttingDown)
1971
2143
  return false;
1972
2144
  // Capped dedupe (the chat-message path, not the unbounded task set): a busy
@@ -2040,6 +2212,7 @@ export class ParallAgentGateway {
2040
2212
  channelCliCapable: cliCapable,
2041
2213
  ackSourceType: 'channel_message',
2042
2214
  ackSourceId: msg.id,
2215
+ dispatchEventId,
2043
2216
  };
2044
2217
  // Release the claim if the event isn't actually dispatched (or throws) so
2045
2218
  // a retry can re-attempt — same contract as the chat-message path.
@@ -2056,7 +2229,7 @@ export class ParallAgentGateway {
2056
2229
  }
2057
2230
  return dispatched;
2058
2231
  }
2059
- async handleExternalTriggerRun(run) {
2232
+ async handleExternalTriggerRun(run, dispatchEventId) {
2060
2233
  if (this.shuttingDown)
2061
2234
  return false;
2062
2235
  const dedupeKey = `external_trigger_run:${run.id}`;
@@ -2084,6 +2257,7 @@ export class ParallAgentGateway {
2084
2257
  attachedUri,
2085
2258
  ackSourceType: 'external_trigger_run',
2086
2259
  ackSourceId: run.id,
2260
+ dispatchEventId,
2087
2261
  };
2088
2262
  let dispatched;
2089
2263
  try {
@@ -2098,7 +2272,7 @@ export class ParallAgentGateway {
2098
2272
  }
2099
2273
  return dispatched;
2100
2274
  }
2101
- async fetchAndHandleApprovalDecided(approvalId, actorId, chatId) {
2275
+ async fetchAndHandleApprovalDecided(approvalId, actorId, chatId, dispatchEventId) {
2102
2276
  let approval = null;
2103
2277
  try {
2104
2278
  approval = await this.opts.client.getApproval(approvalId);
@@ -2131,6 +2305,11 @@ export class ParallAgentGateway {
2131
2305
  senderName: 'approver',
2132
2306
  messageId: approval.id,
2133
2307
  body,
2308
+ // The WorkItem's real source pair. Without it the legacy fallback
2309
+ // guessed ('message', approval_id) — a pair no row matches.
2310
+ ackSourceType: 'approval',
2311
+ ackSourceId: approval.id,
2312
+ dispatchEventId,
2134
2313
  };
2135
2314
  let dispatched;
2136
2315
  try {
@@ -2181,7 +2360,26 @@ export class ParallAgentGateway {
2181
2360
  break;
2182
2361
  if (overflowMode && processed >= CATCHUP_MAX) {
2183
2362
  try {
2184
- await this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id);
2363
+ // Administrative drop of the overflow backlog (summarized to the
2364
+ // agent instead of dispatched). Ledger path: by-id complete —
2365
+ // closes the pending row and refuses (stale) one a live lane
2366
+ // owns; the legacy ack stays as the ledger-disabled / pre-by-id
2367
+ // fallback.
2368
+ if (this.laneLedger && !this.ledgerDisabled && !this.typedByIdCompleteUnsupported) {
2369
+ const outcome = await this.resolveDispatchByID(item.id);
2370
+ if (outcome === 'unsupported') {
2371
+ this.typedByIdCompleteUnsupported = true;
2372
+ await this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id);
2373
+ }
2374
+ else if (outcome === 'failed') {
2375
+ throw new Error('by-id complete failed');
2376
+ }
2377
+ // 'stale' counts as skipped: a live lane owns the row and its
2378
+ // turn will resolve it — nothing left for catch-up to do.
2379
+ }
2380
+ else {
2381
+ await this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id);
2382
+ }
2185
2383
  skipped++;
2186
2384
  const key = item.event_type;
2187
2385
  skippedByType.set(key, (skippedByType.get(key) ?? 0) + 1);
@@ -2193,12 +2391,15 @@ export class ParallAgentGateway {
2193
2391
  }
2194
2392
  processed++;
2195
2393
  try {
2196
- const ackItem = () => this.ackDispatchEvent(item.id, () => this.clearTypedDispatchDedupe(item));
2394
+ const typedHooks = {
2395
+ legacyAck: () => this.ackDispatchEvent(item.id, () => this.clearTypedDispatchDedupe(item)),
2396
+ clearDedupe: () => this.clearTypedDispatchDedupe(item),
2397
+ };
2197
2398
  if (item.event_type === 'task_assign' && item.task_id) {
2198
2399
  try {
2199
2400
  await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.handleTaskDispatch(item.task_id ?? '', item.source_id ?? item.task_id ?? '', {
2200
2401
  dispatchEventId,
2201
- }), ackItem);
2402
+ }), typedHooks);
2202
2403
  }
2203
2404
  catch (err) {
2204
2405
  this.opts.log?.warn(`catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
@@ -2210,7 +2411,7 @@ export class ParallAgentGateway {
2210
2411
  await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.handleTaskDispatch(item.task_id ?? '', item.source_id ?? item.task_id ?? '', {
2211
2412
  allowCreator: true,
2212
2413
  dispatchEventId,
2213
- }), ackItem);
2414
+ }), typedHooks);
2214
2415
  }
2215
2416
  catch (err) {
2216
2417
  this.opts.log?.warn(`catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
@@ -2218,22 +2419,22 @@ export class ParallAgentGateway {
2218
2419
  }
2219
2420
  }
2220
2421
  else if (item.event_type === 'task_comment' && item.source_id && item.task_id) {
2221
- await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.handleTaskComment(item.source_id, item.task_id ?? '', item.actor_id, item.delivery_reason), ackItem);
2422
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.handleTaskComment(item.source_id, item.task_id ?? '', item.actor_id, item.delivery_reason, dispatchEventId), typedHooks);
2222
2423
  }
2223
2424
  else if (item.event_type === 'wiki_comment' && item.source_id) {
2224
- await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.handleWikiComment(item.source_id, item.actor_id, item.delivery_reason), ackItem);
2425
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.handleWikiComment(item.source_id, item.actor_id, item.delivery_reason, dispatchEventId), typedHooks);
2225
2426
  }
2226
2427
  else if (item.event_type === 'schedule.fire' && item.source_id) {
2227
- await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleScheduleFire(item.source_id, item.actor_id), ackItem);
2428
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.fetchAndHandleScheduleFire(item.source_id, item.actor_id, dispatchEventId), typedHooks);
2228
2429
  }
2229
2430
  else if (item.event_type === 'external_trigger' && item.source_id) {
2230
- await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleExternalTriggerRun(item.source_id), ackItem);
2431
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.fetchAndHandleExternalTriggerRun(item.source_id, dispatchEventId), typedHooks);
2231
2432
  }
2232
2433
  else if (item.event_type === 'channel_message' && item.source_id) {
2233
- await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleChannelMessage(item.source_id), ackItem);
2434
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.fetchAndHandleChannelMessage(item.source_id, dispatchEventId), typedHooks);
2234
2435
  }
2235
2436
  else if (item.event_type === 'approval_decided' && item.source_id) {
2236
- await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleApprovalDecided(item.source_id, item.actor_id, item.chat_id ?? null), ackItem);
2437
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.fetchAndHandleApprovalDecided(item.source_id, item.actor_id, item.chat_id ?? null, dispatchEventId), typedHooks);
2237
2438
  }
2238
2439
  else if (item.event_type === 'message' && item.source_id && item.chat_id) {
2239
2440
  await this.consumeMessageWorkItem({