@parall/agent-core 1.44.0 → 1.46.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 (62) hide show
  1. package/dist/channel-capability.d.ts +2 -0
  2. package/dist/channel-capability.d.ts.map +1 -1
  3. package/dist/channel-capability.js +15 -0
  4. package/dist/dispatch-adapter.d.ts +9 -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 +27 -7
  8. package/dist/fork-session-finalizer.d.ts +65 -0
  9. package/dist/fork-session-finalizer.d.ts.map +1 -0
  10. package/dist/fork-session-finalizer.js +70 -0
  11. package/dist/gateway-base.d.ts +55 -0
  12. package/dist/gateway-base.d.ts.map +1 -1
  13. package/dist/gateway-base.js +640 -263
  14. package/dist/gateway-lane-flow.d.ts +75 -5
  15. package/dist/gateway-lane-flow.d.ts.map +1 -1
  16. package/dist/gateway-lane-flow.js +240 -18
  17. package/dist/http-keepalive.d.ts +4 -0
  18. package/dist/http-keepalive.d.ts.map +1 -0
  19. package/dist/http-keepalive.js +33 -0
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +7 -1
  23. package/dist/lane-ledger.d.ts +8 -0
  24. package/dist/lane-ledger.d.ts.map +1 -1
  25. package/dist/lane-ledger.js +14 -0
  26. package/dist/session-lifecycle.d.ts +198 -0
  27. package/dist/session-lifecycle.d.ts.map +1 -0
  28. package/dist/session-lifecycle.js +446 -0
  29. package/dist/skills/parall-clips.d.ts +1 -1
  30. package/dist/skills/parall-clips.d.ts.map +1 -1
  31. package/dist/skills/parall-clips.js +3 -0
  32. package/dist/skills/parall-schedules.d.ts +1 -1
  33. package/dist/skills/parall-schedules.d.ts.map +1 -1
  34. package/dist/skills/parall-schedules.js +1 -1
  35. package/dist/skills/parall-tasks.d.ts +1 -1
  36. package/dist/skills/parall-tasks.d.ts.map +1 -1
  37. package/dist/skills/parall-tasks.js +21 -5
  38. package/dist/step-persister.d.ts +66 -0
  39. package/dist/step-persister.d.ts.map +1 -0
  40. package/dist/step-persister.js +116 -0
  41. package/dist/step-retry-queue.d.ts +91 -0
  42. package/dist/step-retry-queue.d.ts.map +1 -0
  43. package/dist/step-retry-queue.js +259 -0
  44. package/dist/types.d.ts +1 -1
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +3 -2
  47. package/src/channel-capability.ts +16 -0
  48. package/src/dispatch-adapter.ts +10 -0
  49. package/src/event-format.ts +27 -7
  50. package/src/fork-session-finalizer.ts +122 -0
  51. package/src/gateway-base.ts +747 -331
  52. package/src/gateway-lane-flow.ts +275 -18
  53. package/src/http-keepalive.ts +36 -0
  54. package/src/index.ts +7 -1
  55. package/src/lane-ledger.ts +14 -0
  56. package/src/session-lifecycle.ts +552 -0
  57. package/src/skills/parall-clips.ts +3 -0
  58. package/src/skills/parall-schedules.ts +1 -1
  59. package/src/skills/parall-tasks.ts +21 -5
  60. package/src/step-persister.ts +161 -0
  61. package/src/step-retry-queue.ts +296 -0
  62. package/src/types.ts +2 -1
@@ -1,12 +1,17 @@
1
1
  import * as os from 'node:os';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
4
5
  import { ApiError, MENTION_ALL_USER_ID } from '@parall/sdk';
5
6
  import { buildEventBody, buildEventBodyForForkResult, buildForkResultPrefix, buildForkScopePrefix, } from './event-format.js';
7
+ import { CAPABILITY_SLACK_SEND, channelCapabilityKeyFor } from './channel-capability.js';
6
8
  import { buildErrorStepContent, } from './dispatch-adapter.js';
7
- import { consumeMessageWorkItem, consumeTypedDispatch, dispatchLaneGroup, } from './gateway-lane-flow.js';
9
+ import { clearTypedDedupeForEvent, consumeMessageWorkItem, consumeTypedDispatch, dispatchLaneGroup, resolveDispatchByID, settleDrainedTypedGroup, typedLedgerEventIds, } from './gateway-lane-flow.js';
8
10
  import { LaneLedger } from './lane-ledger.js';
9
11
  import { routeTrigger } from './routing.js';
12
+ import { StepPersister, isRetryableStepError } from './step-persister.js';
13
+ import { SessionLifecycleCoordinator } from './session-lifecycle.js';
14
+ import { ForkSessionFinalizer } from './fork-session-finalizer.js';
10
15
  import { clearDispatchMessageId, clearDispatchMetrics, clearDispatchNoReply, clearSessionMessageId, getDispatchMetrics, recordDeliverText, recordMessageSend, recordNoReply, recordToolCall, resetDispatchMetrics, setDispatchMessageId, setDispatchNoReply, setSessionChatId, setSessionMessageId, } from './session-state.js';
11
16
  import { isParallSendCommand, isParallNoReplyCommand, extractShellCommand, } from './bridge-workspace.js';
12
17
  import { startDispatchSpan, endDispatchSpan, recordDispatchMetric, recordMissingReply, runWithSessionKey, } from './telemetry.js';
@@ -43,6 +48,34 @@ export function parseDispatchDeadlineMs(raw) {
43
48
  return undefined;
44
49
  return Math.floor(n);
45
50
  }
51
+ /**
52
+ * Stable input-step idempotency key: a re-delivered event (catch-up replay,
53
+ * lane re-drive) replays to the same row instead of duplicating it.
54
+ *
55
+ * Message-shaped events key on the message id — it is the logical identity
56
+ * and stays stable across delivery paths (a live WS delivery carries no
57
+ * dispatchEventId; its catch-up replay does — keying on the WorkItem there
58
+ * would split the two into different keys and duplicate the input step).
59
+ *
60
+ * Typed events key on the WorkItem id instead: their messageId can be
61
+ * REUSED across distinct work items — task events carry the task id for
62
+ * both task_assign and a later task_update, so keying on messageId would
63
+ * silently dedupe the second, legitimate, input step away. For the same
64
+ * reason a typed event WITHOUT a WorkItem id (legacy server) must NOT fall
65
+ * back to messageId — it gets a random UUID per logical step instead. The
66
+ * key rides the CreateAgentStepRequest object, so the same UUID is reused
67
+ * across that step's HTTP retries and queue redrives (replay-safe), while
68
+ * a redelivered legacy event writes a fresh row (rare duplicate beats
69
+ * silently losing a legitimate step). See protocol-vectors/agent-steps.json.
70
+ */
71
+ export function inputStepIdempotencyKey(event) {
72
+ if (event.type === 'message' || event.type === 'channel_message') {
73
+ return event.messageId ? `input:${event.messageId}` : randomUUID();
74
+ }
75
+ if (event.dispatchEventId)
76
+ return `input:${event.dispatchEventId}`;
77
+ return randomUUID();
78
+ }
46
79
  function resolveStepTarget(event) {
47
80
  if (event.type === 'task' || event.targetId.startsWith('tsk_')) {
48
81
  return { target_type: 'task', target_id: event.targetId };
@@ -141,6 +174,13 @@ export class ParallAgentGateway {
141
174
  heartbeatTimer = null;
142
175
  lastHeartbeatAt = Date.now();
143
176
  draining = false;
177
+ /**
178
+ * Typed WorkItem ids whose drain group left the buffer but has not settled
179
+ * yet. isBufferedTypedWorkItem treats them as still buffered — a re-drive
180
+ * claim taken inside this window would turn the drain's fence-less close
181
+ * stale (its dedupe cleared, its release re-driving handled work) (#1149).
182
+ */
183
+ drainingTypedIds = new Set();
144
184
  // Graceful shutdown state. When SIGTERM / abort fires, `shuttingDown` flips
145
185
  // to true so no new dispatches start, and `inFlightDispatches` counts runs
146
186
  // still in progress. `shutdown()` awaits drain up to SHUTDOWN_DEADLINE_MS
@@ -150,6 +190,16 @@ export class ParallAgentGateway {
150
190
  drainResolvers = [];
151
191
  pendingRestartNotification = null;
152
192
  laneLedger;
193
+ stepPersister;
194
+ // Single entry point for session active/idle writes — serialized
195
+ // desired-state reconciler (see session-lifecycle.ts). The gateway only
196
+ // declares turn boundaries; ordering, retries and stale-finish rejection
197
+ // live in the coordinator.
198
+ sessionLifecycle;
199
+ // Normal fork teardown use case: seal → drain → close → release. The
200
+ // gateway only triggers it; ordering and ownership live in the finalizer
201
+ // (see fork-session-finalizer.ts).
202
+ forkFinalizer;
153
203
  // Sticky fallback: flipped when the server predates the ledger (claim
154
204
  // endpoint 404) so every subsequent dispatch uses the legacy flow.
155
205
  ledgerDisabled = false;
@@ -177,6 +227,34 @@ export class ParallAgentGateway {
177
227
  this.SHUTDOWN_DEADLINE_MS = opts.shutdownDeadlineMs ?? 60_000;
178
228
  this.FORK_DEADLINE_MS = opts.forkDeadlineMs ?? 2 * 60 * 60_000;
179
229
  this.DISPATCH_DEADLINE_MS = opts.dispatchDeadlineMs ?? 20 * 60_000;
230
+ this.stepPersister = new StepPersister({
231
+ client: opts.client,
232
+ orgId: opts.config.org_id,
233
+ agentUserId: opts.agentUserId,
234
+ log: { warn: (msg) => this.opts.log?.warn(msg) },
235
+ isSessionStale: (err) => this.isSessionNotLiveError(err),
236
+ retryDelaysMs: opts.stepRetryDelaysMs,
237
+ });
238
+ this.sessionLifecycle = new SessionLifecycleCoordinator({
239
+ write: (sessionId, payload) => this.opts.client
240
+ .updateAgentSession(this.opts.config.org_id, this.opts.agentUserId, sessionId, payload)
241
+ .then(() => undefined),
242
+ // Same classification as step writes: transient (timeout/network/5xx)
243
+ // retries with backoff; a 4xx is permanent (never loop against it);
244
+ // a terminal-session 409 drops the session for good.
245
+ isRetryable: isRetryableStepError,
246
+ isSessionStale: (err) => this.isSessionNotLiveError(err),
247
+ log: { warn: (msg) => this.opts.log?.warn(msg) },
248
+ retryDelaysMs: opts.lifecycleRetryDelaysMs,
249
+ });
250
+ this.forkFinalizer = new ForkSessionFinalizer({
251
+ steps: this.stepPersister,
252
+ lifecycle: this.sessionLifecycle,
253
+ log: {
254
+ warn: (msg) => this.opts.log?.warn(msg),
255
+ error: (msg) => this.opts.log?.error(msg),
256
+ },
257
+ });
180
258
  if (opts.coldStartWindowMs != null) {
181
259
  opts.log?.warn?.('coldStartWindowMs is deprecated and ignored — cold-start time filter has been removed');
182
260
  }
@@ -230,6 +308,14 @@ export class ParallAgentGateway {
230
308
  const prevId = data.previous_session_id ?? '';
231
309
  this.opts.log?.info(`new session signal received (previous=${prevId})`);
232
310
  this.sessionBindings.clear();
311
+ // The server closed EVERY open session — not just `previous_session_id`
312
+ // (active forks have their own ase_ ids, and sessionBindings was just
313
+ // cleared, so they would otherwise keep retrying against closed rows
314
+ // until a 409 or the age budget). Any parked lifecycle write or step
315
+ // write would now 409 against a terminal row: drop them all rather than
316
+ // burn retry budget and log noise discovering it.
317
+ this.sessionLifecycle.dropAllSessions();
318
+ this.stepPersister.dropAllSessions();
233
319
  if (prevId) {
234
320
  this.pendingRestartNotification = `[Harness Notification] This is a fresh session. Your previous session (${prevId}) was ended by the user and you have been restarted.`;
235
321
  }
@@ -256,25 +342,28 @@ export class ParallAgentGateway {
256
342
  // level claim/ack would consume or clear the wrong sibling.
257
343
  await this.consumeTypedDispatch(data.dispatch_event_id
258
344
  ? { 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, () => {
345
+ : { sourceType: 'task_activity', sourceId: data.id }, (dispatchEventId) => this.handleTaskAssignment(data, data.id, dispatchEventId), {
346
+ legacyAck: (dispatchEventId) => {
347
+ if (dispatchEventId) {
348
+ return this.ackDispatchEvent(dispatchEventId, () => {
349
+ this.dispatchedTasks.delete(`${data.id}:${data.updated_at}`);
350
+ });
351
+ }
352
+ return this.opts.client
353
+ .ackDispatch(this.opts.config.org_id, {
354
+ source_type: 'task_activity',
355
+ source_id: data.id,
356
+ })
357
+ .then(() => true, (err) => {
358
+ // Same contract as ackDispatchEvent: a failed ack is a
359
+ // failed consume (arms backoff) and must free the hot-path
360
+ // dedupe so the re-drive isn't rejected by this pod forever.
262
361
  this.dispatchedTasks.delete(`${data.id}:${data.updated_at}`);
362
+ this.opts.log?.warn(`dispatch ack failed for task ${data.id}, releasing for re-drive: ${String(err)}`);
363
+ return false;
263
364
  });
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
- });
365
+ },
366
+ clearDedupe: () => this.dispatchedTasks.delete(`${data.id}:${data.updated_at}`),
278
367
  });
279
368
  }
280
369
  catch (err) {
@@ -282,6 +371,13 @@ export class ParallAgentGateway {
282
371
  }
283
372
  });
284
373
  ws.on('dispatch.new', async (data) => {
374
+ // Re-drive of a WorkItem whose event copy is already buffered: skip
375
+ // BEFORE the typed claim (see isBufferedTypedWorkItem). The renotify
376
+ // pacing re-checks after the drain settles.
377
+ if (data.event_type !== 'message' && this.isBufferedTypedWorkItem(data.id)) {
378
+ this.opts.log?.info(`typed dispatch ${data.id} already buffered for the drain — skipping re-claim`);
379
+ return;
380
+ }
285
381
  if (data.event_type === 'task_assign') {
286
382
  if (!data.task_id)
287
383
  return;
@@ -296,7 +392,10 @@ export class ParallAgentGateway {
296
392
  if (!data.source_id || !data.task_id)
297
393
  return;
298
394
  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)));
395
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, (dispatchEventId) => this.handleTaskComment(data.source_id, data.task_id ?? '', data.actor_id, data.delivery_reason, dispatchEventId), {
396
+ legacyAck: () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
397
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
398
+ });
300
399
  }
301
400
  catch (err) {
302
401
  this.opts.log?.error(`task comment dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -306,7 +405,10 @@ export class ParallAgentGateway {
306
405
  if (!data.source_id)
307
406
  return;
308
407
  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)));
408
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, (dispatchEventId) => this.handleWikiComment(data.source_id, data.actor_id, data.delivery_reason, dispatchEventId), {
409
+ legacyAck: () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
410
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
411
+ });
310
412
  }
311
413
  catch (err) {
312
414
  this.opts.log?.error(`wiki comment dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -319,7 +421,10 @@ export class ParallAgentGateway {
319
421
  await this.consumeTypedDispatch({ dispatchEventId: data.id }, (dispatchEventId) => this.handleTaskDispatch(data.task_id ?? '', data.source_id ?? data.task_id ?? '', {
320
422
  allowCreator: true,
321
423
  dispatchEventId,
322
- }), () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)));
424
+ }), {
425
+ legacyAck: () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
426
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
427
+ });
323
428
  }
324
429
  catch (err) {
325
430
  this.opts.log?.error(`task update dispatch failed for ${data.task_id}: ${String(err)}`);
@@ -329,7 +434,10 @@ export class ParallAgentGateway {
329
434
  if (!data.source_id)
330
435
  return;
331
436
  try {
332
- await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleScheduleFire(data.source_id, data.actor_id), () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)));
437
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, (dispatchEventId) => this.fetchAndHandleScheduleFire(data.source_id, data.actor_id, dispatchEventId), {
438
+ legacyAck: () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
439
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
440
+ });
333
441
  }
334
442
  catch (err) {
335
443
  this.opts.log?.error(`schedule fire dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -339,7 +447,10 @@ export class ParallAgentGateway {
339
447
  if (!data.source_id)
340
448
  return;
341
449
  try {
342
- await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleExternalTriggerRun(data.source_id), () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)));
450
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, (dispatchEventId) => this.fetchAndHandleExternalTriggerRun(data.source_id, dispatchEventId), {
451
+ legacyAck: () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
452
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
453
+ });
343
454
  }
344
455
  catch (err) {
345
456
  this.opts.log?.error(`external trigger dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -349,7 +460,10 @@ export class ParallAgentGateway {
349
460
  if (!data.source_id)
350
461
  return;
351
462
  try {
352
- await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleChannelMessage(data.source_id), () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)));
463
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, (dispatchEventId) => this.fetchAndHandleChannelMessage(data.source_id, dispatchEventId), {
464
+ legacyAck: () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
465
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
466
+ });
353
467
  }
354
468
  catch (err) {
355
469
  this.opts.log?.error(`channel message dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -359,7 +473,10 @@ export class ParallAgentGateway {
359
473
  if (!data.source_id)
360
474
  return;
361
475
  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)));
476
+ await this.consumeTypedDispatch({ dispatchEventId: data.id }, (dispatchEventId) => this.fetchAndHandleApprovalDecided(data.source_id, data.actor_id, data.chat_id ?? null, dispatchEventId), {
477
+ legacyAck: () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
478
+ clearDedupe: () => this.clearTypedDispatchDedupe(data),
479
+ });
363
480
  }
364
481
  catch (err) {
365
482
  this.opts.log?.error(`approval decided dispatch failed for ${data.source_id}: ${String(err)}`);
@@ -456,6 +573,20 @@ export class ParallAgentGateway {
456
573
  source_id: sourceId,
457
574
  });
458
575
  }
576
+ /**
577
+ * Sticky: the server answered a by-id complete with 400 (predates the
578
+ * form). Typed resolution falls back to the legacy ack for the rest of
579
+ * the process lifetime.
580
+ */
581
+ typedByIdCompleteUnsupported = false;
582
+ // Typed-face ledger helpers live in gateway-lane-flow.ts; thin delegates
583
+ // keep the site code and tests on the class surface.
584
+ typedLedgerEventIds(events) {
585
+ return typedLedgerEventIds(this.laneFlowHost(), events);
586
+ }
587
+ resolveDispatchByID(dispatchEventId, lane) {
588
+ return resolveDispatchByID(this.laneFlowHost(), dispatchEventId, lane);
589
+ }
459
590
  /** True when this event's lifecycle is owned by the dispatch lane ledger. */
460
591
  usesLaneLedger(event) {
461
592
  return this.laneLedger != null && !this.ledgerDisabled && this.laneLedger.handles(event);
@@ -480,7 +611,14 @@ export class ParallAgentGateway {
480
611
  // into one turn.
481
612
  return this.laneLedger.laneKeyFor(event);
482
613
  }
483
- return event.targetId;
614
+ // Typed events never share a drain group with messages. On the
615
+ // ledger-disabled fallback both group by target id, and a typed event
616
+ // whose target is a chat (approval) could batch behind that chat's
617
+ // messages — the drain body carries only the trailing message while the
618
+ // legacy ack sweeps the whole group, silently dropping the typed body
619
+ // (typed events are never steer-injected, so the drain body is their
620
+ // only route to the model). Split the groups instead (#1149).
621
+ return event.type === 'message' ? event.targetId : `typed:${event.targetId}`;
484
622
  }
485
623
  // Lane-flow protocols live in gateway-lane-flow.ts; these thin delegates
486
624
  // keep call sites and tests on the class surface.
@@ -490,15 +628,16 @@ export class ParallAgentGateway {
490
628
  dispatchLaneGroup(opts) {
491
629
  return dispatchLaneGroup(this.laneFlowHost(), opts);
492
630
  }
493
- consumeTypedDispatch(ref, run, ack) {
494
- return consumeTypedDispatch(this.laneFlowHost(), ref, run, ack);
631
+ consumeTypedDispatch(ref, run, hooks) {
632
+ return consumeTypedDispatch(this.laneFlowHost(), ref, run, hooks);
495
633
  }
496
- // Typed completion must wait until the administrative ack has either
497
- // committed or failed. Errors stay best-effort: a failed ack leaves the row
498
- // received, so Complete releases and re-drives it safely. The boolean
499
- // outcome feeds the typed-consume backoff an ack that failed must count
500
- // as a failed consume, or an ack outage would clear the backoff entry and
501
- // let the release re-drive spin at wire speed.
634
+ // Legacy administrative ack (ledger-disabled fallback only). Typed
635
+ // completion must wait until the ack has either committed or failed.
636
+ // Errors stay best-effort: a failed ack leaves the row received, so
637
+ // Complete releases and re-drives it safely. The boolean outcome feeds the
638
+ // typed-consume backoff an ack that failed must count as a failed
639
+ // consume, or an ack outage would clear the backoff entry and let the
640
+ // release re-drive spin at wire speed.
502
641
  ackDispatchEvent(dispatchEventId, onFailure) {
503
642
  return this.opts.client.ackDispatchByID(this.opts.config.org_id, dispatchEventId).then(() => true, (err) => {
504
643
  // Complete will return the still-received item to pending and publish
@@ -509,6 +648,28 @@ export class ParallAgentGateway {
509
648
  return false;
510
649
  });
511
650
  }
651
+ /**
652
+ * True while a typed WorkItem's event copy sits in the main buffer waiting
653
+ * for the drain. A buffered copy keeps its hot-path dedupe claim: the drain
654
+ * owns its settlement, and the released row's re-drive must short-circuit
655
+ * BEFORE claiming (a duplicate claim opens a lane that races the drain's
656
+ * fence-less by-id close — 409 STALE, and the claimant's release would
657
+ * re-drive already-handled work) (#1149). The claim's lifetime equals the
658
+ * buffer stay: settlement clears it on every drain outcome — success,
659
+ * failure, or a thrown turn.
660
+ */
661
+ isBufferedTypedWorkItem(dispatchEventId) {
662
+ if (!dispatchEventId)
663
+ return false;
664
+ return (this.drainingTypedIds.has(dispatchEventId) ||
665
+ this.dispatchState.mainBuffer.some((e) => e.dispatchEventId === dispatchEventId));
666
+ }
667
+ isTypedEventBuffered(event) {
668
+ return this.isBufferedTypedWorkItem(event.dispatchEventId);
669
+ }
670
+ // PARITY: this switch and gateway-lane-flow's clearTypedDedupeForEvent must
671
+ // handle the same typed source families — extend BOTH when adding a typed
672
+ // event type (same dedupe entries, keyed from different event shapes).
512
673
  clearTypedDispatchDedupe(item) {
513
674
  switch (item.event_type) {
514
675
  case 'task_assign':
@@ -572,106 +733,110 @@ export class ParallAgentGateway {
572
733
  }
573
734
  async createInputStep(sessionId, event) {
574
735
  const target = resolveStepTarget(event);
575
- try {
576
- await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
577
- step_type: 'input',
578
- target_type: target.target_type,
579
- target_id: target.target_id,
580
- content: {
581
- trigger_type: event.type === 'task'
582
- ? 'task_assign'
583
- : event.type === 'task_comment'
584
- ? 'task_comment'
585
- : event.type === 'wiki_comment'
586
- ? 'wiki_comment'
587
- : event.type === 'schedule'
588
- ? 'schedule_fire'
589
- : event.type === 'external_trigger'
590
- ? 'external_trigger'
591
- : event.type === 'channel_message'
592
- ? 'channel_message'
593
- : event.type === 'approval'
594
- ? 'approval_decided'
595
- : 'mention',
596
- trigger_ref: event.type === 'task'
597
- ? { task_id: event.targetId }
598
- : event.type === 'task_comment'
599
- ? { comment_id: event.messageId, task_id: event.targetId }
600
- : event.type === 'wiki_comment'
601
- ? { comment_id: event.messageId, target_uri: event.replyTargetUri }
602
- : event.type === 'schedule'
603
- ? { schedule_id: event.targetId, run_id: event.messageId }
604
- : event.type === 'external_trigger'
736
+ await this.stepPersister.persist(sessionId, 'input', {
737
+ step_type: 'input',
738
+ target_type: target.target_type,
739
+ target_id: target.target_id,
740
+ idempotency_key: inputStepIdempotencyKey(event),
741
+ content: {
742
+ trigger_type: event.type === 'task'
743
+ ? 'task_assign'
744
+ : event.type === 'task_comment'
745
+ ? 'task_comment'
746
+ : event.type === 'wiki_comment'
747
+ ? 'wiki_comment'
748
+ : event.type === 'schedule'
749
+ ? 'schedule_fire'
750
+ : event.type === 'external_trigger'
751
+ ? 'external_trigger'
752
+ : event.type === 'channel_message'
753
+ ? 'channel_message'
754
+ : event.type === 'approval'
755
+ ? 'approval_decided'
756
+ : 'mention',
757
+ trigger_ref: event.type === 'task'
758
+ ? { task_id: event.targetId }
759
+ : event.type === 'task_comment'
760
+ ? { comment_id: event.messageId, task_id: event.targetId }
761
+ : event.type === 'wiki_comment'
762
+ ? { comment_id: event.messageId, target_uri: event.replyTargetUri }
763
+ : event.type === 'schedule'
764
+ ? { schedule_id: event.targetId, run_id: event.messageId }
765
+ : event.type === 'external_trigger'
766
+ ? {
767
+ trigger_id: event.targetId,
768
+ run_id: event.messageId,
769
+ connection_id: event.externalConnectionId,
770
+ ingress_event_id: event.externalIngressEventId,
771
+ }
772
+ : event.type === 'channel_message'
605
773
  ? {
606
- trigger_id: event.targetId,
607
- run_id: event.messageId,
608
- connection_id: event.externalConnectionId,
609
- ingress_event_id: event.externalIngressEventId,
774
+ conversation_id: event.targetId,
775
+ channel_message_id: event.messageId,
776
+ provider: event.channelProvider,
777
+ external_conversation_id: event.channelExternalConversationId,
610
778
  }
611
- : event.type === 'channel_message'
612
- ? {
613
- conversation_id: event.targetId,
614
- channel_message_id: event.messageId,
615
- provider: event.channelProvider,
616
- external_conversation_id: event.channelExternalConversationId,
617
- }
618
- : event.type === 'approval'
619
- ? { approval_id: event.messageId }
620
- : { message_id: event.messageId },
621
- sender_id: event.senderId,
622
- sender_name: event.senderName,
623
- summary: event.body.substring(0, 200),
624
- ...(event.sentAt ? { sent_at: event.sentAt } : {}),
625
- },
626
- });
627
- }
628
- catch (err) {
629
- if (this.isSessionNotLiveError(err))
630
- throw err;
631
- this.opts.log?.warn(`failed to create input step: ${String(err)}`);
632
- }
779
+ : event.type === 'approval'
780
+ ? { approval_id: event.messageId }
781
+ : { message_id: event.messageId },
782
+ sender_id: event.senderId,
783
+ sender_name: event.senderName,
784
+ summary: event.body.substring(0, 200),
785
+ ...(event.sentAt ? { sent_at: event.sentAt } : {}),
786
+ },
787
+ });
633
788
  }
634
789
  async createRuntimeStep(sessionId, event, runtimeEvent, stepIdFilePath, contextFilePath, laneContextFilePath) {
635
790
  const target = resolveStepTarget(event);
636
- try {
637
- switch (runtimeEvent.type) {
638
- case 'thinking':
639
- await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
640
- step_type: 'thinking',
641
- target_type: target.target_type,
642
- target_id: target.target_id,
643
- content: { text: runtimeEvent.text },
644
- group_key: runtimeEvent.groupKey,
645
- });
646
- break;
647
- case 'text':
648
- await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
649
- step_type: 'text',
650
- target_type: target.target_type,
651
- target_id: target.target_id,
652
- content: {
653
- text: runtimeEvent.text,
654
- suppressed: runtimeEvent.project !== true,
655
- },
656
- projection: runtimeEvent.project === true,
657
- group_key: runtimeEvent.groupKey,
658
- });
659
- break;
660
- case 'tool_call': {
661
- const step = await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
662
- step_type: 'tool_call',
663
- target_type: target.target_type,
664
- target_id: target.target_id,
665
- content: {
666
- call_id: runtimeEvent.callId,
667
- tool_name: runtimeEvent.toolName,
668
- tool_input: runtimeEvent.input,
669
- status: 'running',
670
- started_at: runtimeEvent.startedAt ?? new Date().toISOString(),
671
- },
672
- group_key: runtimeEvent.groupKey,
673
- runtime_key: runtimeEvent.callId,
674
- });
791
+ switch (runtimeEvent.type) {
792
+ case 'thinking':
793
+ await this.stepPersister.persist(sessionId, 'thinking', {
794
+ step_type: 'thinking',
795
+ target_type: target.target_type,
796
+ target_id: target.target_id,
797
+ idempotency_key: randomUUID(),
798
+ content: { text: runtimeEvent.text },
799
+ group_key: runtimeEvent.groupKey,
800
+ });
801
+ break;
802
+ case 'text':
803
+ await this.stepPersister.persist(sessionId, 'text', {
804
+ step_type: 'text',
805
+ target_type: target.target_type,
806
+ target_id: target.target_id,
807
+ idempotency_key: randomUUID(),
808
+ content: {
809
+ text: runtimeEvent.text,
810
+ suppressed: runtimeEvent.project !== true,
811
+ },
812
+ projection: runtimeEvent.project === true,
813
+ group_key: runtimeEvent.groupKey,
814
+ });
815
+ break;
816
+ case 'tool_call': {
817
+ const step = await this.stepPersister.persist(sessionId, 'tool_call', {
818
+ step_type: 'tool_call',
819
+ target_type: target.target_type,
820
+ target_id: target.target_id,
821
+ // call_id is session-unique for bridge runtimes (server-enforced),
822
+ // so the bare form anchors the tool step pair across retries —
823
+ // unlike parel's turn-scoped `tc:{turnId}:{callId}` (see
824
+ // protocol-vectors/agent-steps.json).
825
+ idempotency_key: `tc:${runtimeEvent.callId}`,
826
+ content: {
827
+ call_id: runtimeEvent.callId,
828
+ tool_name: runtimeEvent.toolName,
829
+ tool_input: runtimeEvent.input,
830
+ status: 'running',
831
+ started_at: runtimeEvent.startedAt ?? new Date().toISOString(),
832
+ },
833
+ group_key: runtimeEvent.groupKey,
834
+ runtime_key: runtimeEvent.callId,
835
+ });
836
+ // step is null when the write was queued for background retry — the
837
+ // CLI step-id linkage window has then passed, same as a failed write
838
+ // before the queue existed.
839
+ if (step) {
675
840
  if (contextFilePath) {
676
841
  this.updateContextFileStepId(contextFilePath, step.id);
677
842
  }
@@ -681,48 +846,47 @@ export class ParallAgentGateway {
681
846
  if (laneContextFilePath) {
682
847
  this.updateContextFileStepId(laneContextFilePath, step.id);
683
848
  }
684
- break;
685
849
  }
686
- case 'tool_result':
687
- await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
688
- step_type: 'tool_result',
689
- target_type: target.target_type,
690
- target_id: target.target_id,
691
- content: {
692
- call_id: runtimeEvent.callId,
693
- tool_name: runtimeEvent.toolName,
694
- status: runtimeEvent.error ? 'error' : 'success',
695
- output: runtimeEvent.output,
696
- duration_ms: runtimeEvent.durationMs ?? 0,
697
- collapsible: true,
698
- },
699
- group_key: runtimeEvent.groupKey,
700
- });
701
- if (contextFilePath) {
702
- this.updateContextFileStepId(contextFilePath, null);
703
- }
704
- else if (stepIdFilePath) {
705
- this.clearStepIdFile(stepIdFilePath);
706
- }
707
- if (laneContextFilePath) {
708
- this.updateContextFileStepId(laneContextFilePath, null);
709
- }
710
- break;
711
- case 'error':
712
- await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
713
- step_type: 'text',
714
- target_type: target.target_type,
715
- target_id: target.target_id,
716
- content: buildErrorStepContent(runtimeEvent.message),
717
- projection: false,
718
- });
719
- break;
850
+ break;
720
851
  }
721
- }
722
- catch (err) {
723
- if (this.isSessionNotLiveError(err))
724
- throw err;
725
- this.opts.log?.warn(`failed to create ${runtimeEvent.type} step: ${String(err)}`);
852
+ case 'tool_result':
853
+ await this.stepPersister.persist(sessionId, 'tool_result', {
854
+ step_type: 'tool_result',
855
+ target_type: target.target_type,
856
+ target_id: target.target_id,
857
+ idempotency_key: `tr:${runtimeEvent.callId}`,
858
+ content: {
859
+ call_id: runtimeEvent.callId,
860
+ tool_name: runtimeEvent.toolName,
861
+ status: runtimeEvent.error ? 'error' : 'success',
862
+ output: runtimeEvent.output,
863
+ duration_ms: runtimeEvent.durationMs ?? 0,
864
+ collapsible: true,
865
+ },
866
+ group_key: runtimeEvent.groupKey,
867
+ });
868
+ // The tool has finished regardless of whether the step write landed
869
+ // inline or was queued — always clear the step-id linkage.
870
+ if (contextFilePath) {
871
+ this.updateContextFileStepId(contextFilePath, null);
872
+ }
873
+ else if (stepIdFilePath) {
874
+ this.clearStepIdFile(stepIdFilePath);
875
+ }
876
+ if (laneContextFilePath) {
877
+ this.updateContextFileStepId(laneContextFilePath, null);
878
+ }
879
+ break;
880
+ case 'error':
881
+ await this.stepPersister.persist(sessionId, 'error', {
882
+ step_type: 'text',
883
+ target_type: target.target_type,
884
+ target_id: target.target_id,
885
+ idempotency_key: randomUUID(),
886
+ content: buildErrorStepContent(runtimeEvent.message),
887
+ projection: false,
888
+ });
889
+ break;
726
890
  }
727
891
  }
728
892
  writeContextFile(filePath, ctx) {
@@ -906,9 +1070,20 @@ export class ParallAgentGateway {
906
1070
  : null;
907
1071
  let binding = this.sessionBindings.get(sessionKey);
908
1072
  let inputStepsCreated = false;
909
- let triggerMessageSet = false;
1073
+ let turnHandle;
910
1074
  let dispatchError;
911
1075
  const pendingSendCallIds = new Set();
1076
+ // Turn boundary: must complete one bounded active reconciliation
1077
+ // BEFORE this turn's first AgentStep persists — a reused idle session
1078
+ // otherwise races the status write and the server presence guard
1079
+ // swallows the turn's first activity. Resolves even when the write
1080
+ // fails (warned; the coordinator keeps reconciling in the background)
1081
+ // so a degraded link never blocks the step flow indefinitely.
1082
+ const ensureTurnBegun = async () => {
1083
+ if (turnHandle || !binding)
1084
+ return;
1085
+ turnHandle = await this.sessionLifecycle.beginTurn(binding.agentSessionId, event.messageId);
1086
+ };
912
1087
  try {
913
1088
  dispatchSpan = startDispatchSpan(event, this.opts.runtimeType, sessionKey);
914
1089
  for await (const runtimeEvent of this.opts.dispatchAdapter.dispatch({
@@ -938,6 +1113,7 @@ export class ParallAgentGateway {
938
1113
  // while a dispatch was in flight) inside the in-flight window so a
939
1114
  // shutdown short-circuit BEFORE this point cannot leave orphan input
940
1115
  // steps that the replacement pod would duplicate on replay.
1116
+ await ensureTurnBegun();
941
1117
  if (earlierEvents.length > 0) {
942
1118
  await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
943
1119
  }
@@ -950,12 +1126,7 @@ export class ParallAgentGateway {
950
1126
  const detail = runtimeEvent.type === 'error' ? `: ${runtimeEvent.message}` : '';
951
1127
  throw new Error(`runtime emitted ${runtimeEvent.type} before runtime_session${detail}`);
952
1128
  }
953
- if (!triggerMessageSet) {
954
- triggerMessageSet = true;
955
- this.opts.client
956
- .updateAgentSession(this.opts.config.org_id, this.opts.agentUserId, binding.agentSessionId, { status: 'active', trigger_message_id: event.messageId })
957
- .catch((err) => this.opts.log?.warn?.(`failed to set session active: ${err}`));
958
- }
1129
+ await ensureTurnBegun();
959
1130
  if (!inputStepsCreated) {
960
1131
  if (earlierEvents.length > 0) {
961
1132
  await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
@@ -1001,6 +1172,7 @@ export class ParallAgentGateway {
1001
1172
  throw new Error('runtime completed without runtime_session');
1002
1173
  }
1003
1174
  if (!inputStepsCreated) {
1175
+ await ensureTurnBegun();
1004
1176
  if (earlierEvents.length > 0) {
1005
1177
  await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
1006
1178
  }
@@ -1012,6 +1184,7 @@ export class ParallAgentGateway {
1012
1184
  let staleDetected = this.isSessionNotLiveError(err);
1013
1185
  if (!staleDetected && binding) {
1014
1186
  try {
1187
+ await ensureTurnBegun();
1015
1188
  await this.createRuntimeStep(binding.agentSessionId, event, {
1016
1189
  type: 'error',
1017
1190
  message: `Dispatch failed: ${String(err)}`,
@@ -1025,6 +1198,8 @@ export class ParallAgentGateway {
1025
1198
  if (staleDetected && binding) {
1026
1199
  this.opts.log?.warn?.(`session ${binding.agentSessionId} is stale (mid-dispatch), triggering recovery for ${sessionKey}`);
1027
1200
  this.sessionBindings.delete(sessionKey);
1201
+ this.stepPersister.dropSession(binding.agentSessionId);
1202
+ this.sessionLifecycle.dropSession(binding.agentSessionId);
1028
1203
  if (sessionKey === this.opts.runtimeKey) {
1029
1204
  this.activeSessionId = undefined;
1030
1205
  }
@@ -1057,10 +1232,10 @@ export class ParallAgentGateway {
1057
1232
  recordMissingReply(this.opts.runtimeType);
1058
1233
  }
1059
1234
  clearDispatchMetrics(sessionKey);
1060
- if (triggerMessageSet && binding) {
1061
- this.opts.client
1062
- .updateAgentSession(this.opts.config.org_id, this.opts.agentUserId, binding.agentSessionId, { status: 'idle' })
1063
- .catch((err) => this.opts.log?.warn?.(`failed to set session idle: ${err}`));
1235
+ if (turnHandle) {
1236
+ // Stale-handle safe: if a newer turn already began on this
1237
+ // session, the coordinator ignores this finish outright.
1238
+ this.sessionLifecycle.finishTurn(turnHandle);
1064
1239
  }
1065
1240
  clearSessionMessageId(sessionKey);
1066
1241
  clearDispatchMessageId(sessionKey);
@@ -1190,6 +1365,19 @@ export class ParallAgentGateway {
1190
1365
  }
1191
1366
  else {
1192
1367
  dispatched = await this.runDispatch(last, fork.fork.sessionKey, buildForkScopePrefix(last) + buildEventBody(last), earlier, batchText);
1368
+ if (dispatched &&
1369
+ this.typedLedgerEventIds(events) &&
1370
+ this.consumeTurnError(fork.fork.sessionKey)) {
1371
+ // Typed error turn in a fork: report failure to the awaiting
1372
+ // typed consumes so the members release for a budgeted retry,
1373
+ // and do NOT record the events as handled — the redrive must
1374
+ // not arrive wearing an "already handled" prefix. Keep draining
1375
+ // (an error turn is not a shutdown).
1376
+ this.opts.log?.info(`typed fork turn for ${last.messageId} surfaced a runtime error — releasing for retry`);
1377
+ for (const item of items)
1378
+ item.resolve(false);
1379
+ continue;
1380
+ }
1193
1381
  }
1194
1382
  if (!dispatched) {
1195
1383
  // Shutdown short-circuit — resolve un-acked so the server requeues
@@ -1289,10 +1477,18 @@ export class ParallAgentGateway {
1289
1477
  }
1290
1478
  }
1291
1479
  if (forkBinding) {
1292
- this.opts.client
1293
- .updateAgentSession(this.opts.config.org_id, this.opts.agentUserId, forkBinding.agentSessionId, { status: 'closed' })
1294
- .catch(() => { });
1295
- this.sessionBindings.delete(fork.fork.sessionKey);
1480
+ // Normal fork teardown is the ForkSessionFinalizer use case:
1481
+ // seal drain parked/in-flight step writes → close (serialized
1482
+ // behind the turn's idle, ownership held through close retries)
1483
+ // release the binding. The gateway only triggers it. Notably this
1484
+ // must NOT drop the session's step queue — parked steps drain to
1485
+ // the server before the close is issued (the identity check on the
1486
+ // release keeps a replacement fork's binding intact).
1487
+ await this.forkFinalizer.finalize(forkBinding.agentSessionId, () => {
1488
+ if (this.sessionBindings.get(fork.fork.sessionKey) === forkBinding) {
1489
+ this.sessionBindings.delete(fork.fork.sessionKey);
1490
+ }
1491
+ });
1296
1492
  }
1297
1493
  }
1298
1494
  }
@@ -1366,34 +1562,106 @@ export class ParallAgentGateway {
1366
1562
  // no_action sweep); no legacy acks.
1367
1563
  continue;
1368
1564
  }
1565
+ // Buffered typed events are the one wrapper-less dispatch path: their
1566
+ // consumeTypedDispatch guard returned long ago (buffer-main resolves
1567
+ // false) and released the claims, so THIS site owns their resolution.
1568
+ // Message groups here are the ledger-disabled legacy flow.
1569
+ const typedRefs = this.typedLedgerEventIds(events);
1570
+ // Guard the settlement window: from the moment the group leaves the
1571
+ // buffer until settlement finishes, a re-drive must still be
1572
+ // absorbed by isBufferedTypedWorkItem — see drainingTypedIds. Keyed
1573
+ // on the events' own WorkItem ids, NOT typedRefs: the legacy
1574
+ // (ledger-disabled) fallback settles through per-event acks but its
1575
+ // re-drives converge through the same buffered-WorkItem check.
1576
+ const drainingIds = events
1577
+ .map((ev) => ev.dispatchEventId)
1578
+ .filter((id) => !!id);
1579
+ for (const id of drainingIds)
1580
+ this.drainingTypedIds.add(id);
1369
1581
  try {
1370
- await this.emitDispatchReceived(event);
1371
- }
1372
- catch (err) {
1373
- this.opts.log?.warn?.(`mark-received failed for buffered dispatch, leaving unacked for retry: ${String(err)}`);
1374
- this.dispatchState.mainBuffer.unshift(...events);
1375
- this.dispatchState.pendingForkResults.unshift(...pendingFork);
1376
- break;
1377
- }
1378
- const dispatched = await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event), earlier);
1379
- if (!dispatched) {
1380
- // Shutdown: skip the ack so the server redelivers these buffered
1381
- // events to the replacement pod via dispatch catch-up. Put both the
1382
- // buffered events and the fork results back so nothing is lost.
1383
- this.dispatchState.mainBuffer.unshift(...events);
1384
- this.dispatchState.pendingForkResults.unshift(...pendingFork);
1385
- break;
1582
+ if (!typedRefs) {
1583
+ try {
1584
+ await this.emitDispatchReceived(event);
1585
+ }
1586
+ catch (err) {
1587
+ this.opts.log?.warn?.(`mark-received failed for buffered dispatch, leaving unacked for retry: ${String(err)}`);
1588
+ this.dispatchState.mainBuffer.unshift(...events);
1589
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1590
+ break;
1591
+ }
1592
+ }
1593
+ // A typed group dispatches ALL buffered bodies, not just the
1594
+ // newest: typed events are never steer-injected (unlike messages,
1595
+ // which the model already saw mid-turn), so a comment burst folded
1596
+ // into one drain turn would otherwise surface only its last member
1597
+ // to the LLM — earlier ones exist solely as input steps the model
1598
+ // never reads. Keyed on the event kind, NOT on typedRefs: the
1599
+ // ledger-disabled fallback buffers the same bursts and owes the
1600
+ // model the same visibility (groups are homogeneous — see
1601
+ // dispatchGroupKey). Adapters that present earlierEvents natively
1602
+ // (OpenClaw InboundHistory) are exempt — concatenating would show
1603
+ // every earlier member twice.
1604
+ const isTypedGroup = events.every((ev) => ev.type !== 'message');
1605
+ const body = isTypedGroup &&
1606
+ events.length > 1 &&
1607
+ this.opts.dispatchAdapter.earlierEventsInPrompt !== true
1608
+ ? events.map((ev) => buildEventBody(ev)).join('\n\n')
1609
+ : buildEventBody(event);
1610
+ let dispatched;
1611
+ try {
1612
+ dispatched = await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + body, earlier);
1613
+ }
1614
+ catch (err) {
1615
+ if (!isTypedGroup)
1616
+ throw err;
1617
+ // The retained dedupe claims live exactly as long as the buffer
1618
+ // stay — a thrown turn dropped these events without settlement,
1619
+ // so free the claims here or every re-drive is rejected at the
1620
+ // dedupe gate until restart (#1149).
1621
+ this.opts.log?.error(`typed drain dispatch failed for ${event.messageId} (group of ${events.length}, claims freed for re-drive): ${String(err)}`);
1622
+ for (const ev of events)
1623
+ clearTypedDedupeForEvent(this.laneFlowHost(), ev);
1624
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1625
+ continue;
1626
+ }
1627
+ if (!dispatched) {
1628
+ // Shutdown: skip the ack so the server redelivers these buffered
1629
+ // events to the replacement pod via dispatch catch-up. Put both
1630
+ // the buffered events and the fork results back so nothing is
1631
+ // lost.
1632
+ this.dispatchState.mainBuffer.unshift(...events);
1633
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1634
+ break;
1635
+ }
1636
+ if (typedRefs) {
1637
+ // Wrapper-less resolution of the buffered typed group — protocol
1638
+ // lives in gateway-lane-flow.ts. The turn-error marker is
1639
+ // consumed HERE, before this loop can start another turn on the
1640
+ // session.
1641
+ await settleDrainedTypedGroup(this.laneFlowHost(), events, typedRefs, this.consumeTurnError(this.opts.runtimeKey));
1642
+ continue;
1643
+ }
1644
+ for (const bufferedEvent of events) {
1645
+ const sourceType = bufferedEvent.ackSourceType ??
1646
+ (bufferedEvent.type === 'task' ? 'task_activity' : 'message');
1647
+ const sourceId = bufferedEvent.ackSourceId ?? bufferedEvent.messageId;
1648
+ this.opts.client
1649
+ .ackDispatch(this.opts.config.org_id, {
1650
+ source_type: sourceType,
1651
+ source_id: sourceId,
1652
+ })
1653
+ .catch(() => { });
1654
+ // Retained-claim lifetime is the buffer stay on the legacy face
1655
+ // too: the fire-and-forget ack may fail (the row re-drives and
1656
+ // must not be self-rejected), and a shared-key task sibling must
1657
+ // not be blocked by this drained copy's claim. No-op for message
1658
+ // events (no typed dedupe entry).
1659
+ clearTypedDedupeForEvent(this.laneFlowHost(), bufferedEvent);
1660
+ }
1386
1661
  }
1387
- for (const bufferedEvent of events) {
1388
- const sourceType = bufferedEvent.ackSourceType ??
1389
- (bufferedEvent.type === 'task' ? 'task_activity' : 'message');
1390
- const sourceId = bufferedEvent.ackSourceId ?? bufferedEvent.messageId;
1391
- this.opts.client
1392
- .ackDispatch(this.opts.config.org_id, {
1393
- source_type: sourceType,
1394
- source_id: sourceId,
1395
- })
1396
- .catch(() => { });
1662
+ finally {
1663
+ for (const id of drainingIds)
1664
+ this.drainingTypedIds.delete(id);
1397
1665
  }
1398
1666
  }
1399
1667
  }
@@ -1464,21 +1732,36 @@ export class ParallAgentGateway {
1464
1732
  }
1465
1733
  return outcome === 'dispatched';
1466
1734
  }
1467
- try {
1468
- await this.emitDispatchReceived(event);
1469
- }
1470
- catch (err) {
1471
- this.opts.log?.warn?.(`mark-received failed, leaving unacked for retry: ${String(err)}`);
1472
- this.dispatchState.mainDispatching = false;
1473
- this.dispatchState.mainCurrentTargetId = undefined;
1474
- this.mainCurrentGroupKey = undefined;
1475
- this.dispatchState.mainPreDispatchBranchPoint = undefined;
1476
- this.dispatchState.pendingForkResults.unshift(...pendingFork);
1477
- return false;
1735
+ // Ledger-claimed typed events skip the legacy received write — the
1736
+ // claim already marked the row received, and a second mark here would
1737
+ // wipe its lease owner (the 0713 black-hole shape on the typed face).
1738
+ const typedRefs = this.typedLedgerEventIds([event]);
1739
+ if (!typedRefs) {
1740
+ try {
1741
+ await this.emitDispatchReceived(event);
1742
+ }
1743
+ catch (err) {
1744
+ this.opts.log?.warn?.(`mark-received failed, leaving unacked for retry: ${String(err)}`);
1745
+ this.dispatchState.mainDispatching = false;
1746
+ this.dispatchState.mainCurrentTargetId = undefined;
1747
+ this.mainCurrentGroupKey = undefined;
1748
+ this.dispatchState.mainPreDispatchBranchPoint = undefined;
1749
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1750
+ return false;
1751
+ }
1478
1752
  }
1479
1753
  let dispatched = false;
1480
1754
  try {
1481
1755
  dispatched = await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event));
1756
+ if (dispatched && typedRefs && this.consumeTurnError(this.opts.runtimeKey)) {
1757
+ // Typed error turn: report failure to the enclosing typed consume
1758
+ // so the member releases for a budgeted retry instead of being
1759
+ // terminally resolved (tech-debt: typed-dispatch-error-outcome).
1760
+ // Consumed HERE, before the finally's drain can start another
1761
+ // turn on this session and clear the marker.
1762
+ this.opts.log?.info(`typed dispatch turn for ${event.messageId} surfaced a runtime error — releasing for retry`);
1763
+ dispatched = false;
1764
+ }
1482
1765
  if (!dispatched) {
1483
1766
  // Shutdown short-circuit — restore the fork results so a future
1484
1767
  // pod can replay them, and return false so handleMessage skips ack.
@@ -1494,10 +1777,27 @@ export class ParallAgentGateway {
1494
1777
  if (this.shuttingDown) {
1495
1778
  return false;
1496
1779
  }
1780
+ // A re-driven typed WorkItem may already be buffered from a prior
1781
+ // consume attempt (claim → busy main → buffer → release → server
1782
+ // re-drive): the buffered copy is the one the drain settles, so a
1783
+ // second copy would double the drain group's input steps and prompt
1784
+ // content. Drop the duplicate; the caller releases the row again and
1785
+ // the re-drive keeps converging on the buffered copy (#1149).
1786
+ if (this.isBufferedTypedWorkItem(event.dispatchEventId)) {
1787
+ return false;
1788
+ }
1497
1789
  // Push synchronously BEFORE the (possibly async) steer attempt so
1498
1790
  // arrival order is preserved and the event cannot be orphaned in a
1499
1791
  // gap between the steer await and the push.
1500
1792
  this.dispatchState.mainBuffer.push(event);
1793
+ // FIFO fence for BOTH injection branches below: a buffered typed
1794
+ // event is never injected, but the adapters track pending injections
1795
+ // as a COUNT, not by identity — if a message injected behind a
1796
+ // buffered typed event, the drain (typed group first, FIFO) would
1797
+ // consume the message's steer output as the typed group's turn: the
1798
+ // typed body never reaches the model yet resolves, and the message
1799
+ // replays. When anything un-injected sits ahead, buffer only.
1800
+ const typedAheadInBuffer = this.dispatchState.mainBuffer.some((e) => e.type !== 'message');
1501
1801
  if (this.usesLaneLedger(event)) {
1502
1802
  // Ledger flow: fold into the live lane server-side FIRST, then
1503
1803
  // inject. An un-folded injection is forbidden (the pending WorkItem
@@ -1511,14 +1811,25 @@ export class ParallAgentGateway {
1511
1811
  // current turn's reply without the model ever seeing it. Leaving
1512
1812
  // the event un-folded keeps it buffered; the drain claims it as
1513
1813
  // its own turn and folds it there.
1514
- if (this.mainCurrentGroupKey === this.dispatchGroupKey(event) &&
1814
+ if (!typedAheadInBuffer &&
1815
+ this.mainCurrentGroupKey === this.dispatchGroupKey(event) &&
1515
1816
  this.opts.dispatchAdapter.enqueueDuringDispatch != null &&
1516
1817
  (await this.laneLedger?.steerLive(event)) &&
1517
1818
  (await this.opts.dispatchAdapter.enqueueDuringDispatch(this.opts.runtimeKey, buildEventBody(event)))) {
1518
1819
  this.opts.log?.info(`steer folded+injected for ${event.messageId} (will drain for bookkeeping)`);
1519
1820
  }
1520
1821
  }
1521
- else if (this.dispatchState.mainCurrentTargetId === event.targetId &&
1822
+ else if (
1823
+ // Message events only. A typed event (task_comment/schedule/…)
1824
+ // rides the typed-consume contract — buffer-main resolves false and
1825
+ // the claim releases for re-drive — so an injection here is exactly
1826
+ // the forbidden un-folded injection: the LLM sees the content while
1827
+ // the WorkItem stays live, and every re-drive injects it AGAIN (the
1828
+ // 7/16 watcher duplicate-delivery loop, #1149). Typed events stay
1829
+ // buffered; the drain claims them as their own turn.
1830
+ event.type === 'message' &&
1831
+ !typedAheadInBuffer &&
1832
+ this.dispatchState.mainCurrentTargetId === event.targetId &&
1522
1833
  (await this.opts.dispatchAdapter.enqueueDuringDispatch?.(this.opts.runtimeKey, buildEventBody(event)))) {
1523
1834
  this.opts.log?.info(`steer injected for ${event.messageId} (will drain for bookkeeping)`);
1524
1835
  }
@@ -1552,8 +1863,9 @@ export class ParallAgentGateway {
1552
1863
  // — the fork's lane claim marks them received. A mark-received here
1553
1864
  // outruns the claim, strands the row ownerless, and the chat goes
1554
1865
  // silent (the 0713 black hole; dispatch-convergence-design.md §6).
1555
- // This was the only unguarded call site of the three.
1556
- if (!this.usesLaneLedger(event)) {
1866
+ // Same rule for ledger-claimed typed events: their dsp-lane claim
1867
+ // owns received-ness, and a re-mark would wipe the lease owner.
1868
+ if (!this.usesLaneLedger(event) && !this.typedLedgerEventIds([event])) {
1557
1869
  try {
1558
1870
  await this.emitDispatchReceived(event);
1559
1871
  }
@@ -1748,7 +2060,10 @@ export class ParallAgentGateway {
1748
2060
  return;
1749
2061
  await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.handleTaskDispatch(item.task_id ?? '', item.source_id ?? item.task_id ?? '', {
1750
2062
  dispatchEventId,
1751
- }), (dispatchEventId) => this.ackDispatchEvent(dispatchEventId ?? item.id, () => this.clearTypedDispatchDedupe(item)));
2063
+ }), {
2064
+ legacyAck: (dispatchEventId) => this.ackDispatchEvent(dispatchEventId ?? item.id, () => this.clearTypedDispatchDedupe(item)),
2065
+ clearDedupe: () => this.clearTypedDispatchDedupe(item),
2066
+ });
1752
2067
  }
1753
2068
  consumeMessageWorkItem(item) {
1754
2069
  return consumeMessageWorkItem(this.laneFlowHost(), item);
@@ -1786,7 +2101,7 @@ export class ParallAgentGateway {
1786
2101
  dispatchEventId,
1787
2102
  };
1788
2103
  const dispatched = await this.handleInboundEvent(event);
1789
- if (!dispatched) {
2104
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
1790
2105
  this.dispatchedTasks.delete(dedupeKey);
1791
2106
  }
1792
2107
  return dispatched;
@@ -1810,7 +2125,7 @@ export class ParallAgentGateway {
1810
2125
  }
1811
2126
  return this.handleTaskAssignment(task, ackSourceId, opts.dispatchEventId);
1812
2127
  }
1813
- async handleTaskComment(commentId, taskId, actorId, deliveryReason) {
2128
+ async handleTaskComment(commentId, taskId, actorId, deliveryReason, dispatchEventId) {
1814
2129
  if (this.shuttingDown)
1815
2130
  return false; // drain window — let server requeue via catch-up
1816
2131
  const dedupeKey = `comment:${commentId}`;
@@ -1872,6 +2187,7 @@ export class ParallAgentGateway {
1872
2187
  deliveryReason: deliveryReason ?? undefined,
1873
2188
  ackSourceType: 'comment',
1874
2189
  ackSourceId: commentId,
2190
+ dispatchEventId,
1875
2191
  };
1876
2192
  let dispatched;
1877
2193
  try {
@@ -1882,12 +2198,12 @@ export class ParallAgentGateway {
1882
2198
  this.dispatchedTasks.delete(dedupeKey);
1883
2199
  throw err;
1884
2200
  }
1885
- if (!dispatched) {
2201
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
1886
2202
  this.dispatchedTasks.delete(dedupeKey);
1887
2203
  }
1888
2204
  return dispatched;
1889
2205
  }
1890
- async handleWikiComment(commentId, actorId, deliveryReason) {
2206
+ async handleWikiComment(commentId, actorId, deliveryReason, dispatchEventId) {
1891
2207
  if (this.shuttingDown)
1892
2208
  return false; // drain window — let server requeue via catch-up
1893
2209
  // Shares the comment dedupe namespace with handleTaskComment; comment IDs
@@ -1940,6 +2256,7 @@ export class ParallAgentGateway {
1940
2256
  replyTargetUri: comment.target_uri,
1941
2257
  ackSourceType: 'comment',
1942
2258
  ackSourceId: commentId,
2259
+ dispatchEventId,
1943
2260
  };
1944
2261
  let dispatched;
1945
2262
  try {
@@ -1950,7 +2267,7 @@ export class ParallAgentGateway {
1950
2267
  this.dispatchedTasks.delete(dedupeKey);
1951
2268
  throw err;
1952
2269
  }
1953
- if (!dispatched) {
2270
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
1954
2271
  this.dispatchedTasks.delete(dedupeKey);
1955
2272
  }
1956
2273
  return dispatched;
@@ -1963,7 +2280,7 @@ export class ParallAgentGateway {
1963
2280
  * access to already-delivered run snapshots, and the runtime must not crash
1964
2281
  * or retry forever in that case.
1965
2282
  */
1966
- async fetchAndHandleScheduleFire(runId, actorId) {
2283
+ async fetchAndHandleScheduleFire(runId, actorId, dispatchEventId) {
1967
2284
  let run = null;
1968
2285
  try {
1969
2286
  run = await this.opts.client.getScheduleRun(this.opts.config.org_id, runId);
@@ -1984,9 +2301,9 @@ export class ParallAgentGateway {
1984
2301
  }
1985
2302
  if (!run)
1986
2303
  return true;
1987
- return this.handleScheduleFire(run, actorId);
2304
+ return this.handleScheduleFire(run, actorId, dispatchEventId);
1988
2305
  }
1989
- async handleScheduleFire(run, actorId) {
2306
+ async handleScheduleFire(run, actorId, dispatchEventId) {
1990
2307
  if (this.shuttingDown)
1991
2308
  return false; // drain window — let server requeue via catch-up
1992
2309
  const dedupeKey = `schedule_run:${run.id}`;
@@ -2010,6 +2327,7 @@ export class ParallAgentGateway {
2010
2327
  attachedUri: run.fired_attached_uri ?? undefined,
2011
2328
  ackSourceType: 'schedule_run',
2012
2329
  ackSourceId: run.id,
2330
+ dispatchEventId,
2013
2331
  };
2014
2332
  let dispatched;
2015
2333
  try {
@@ -2019,12 +2337,12 @@ export class ParallAgentGateway {
2019
2337
  this.dispatchedTasks.delete(dedupeKey);
2020
2338
  throw err;
2021
2339
  }
2022
- if (!dispatched) {
2340
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
2023
2341
  this.dispatchedTasks.delete(dedupeKey);
2024
2342
  }
2025
2343
  return dispatched;
2026
2344
  }
2027
- async fetchAndHandleExternalTriggerRun(runId) {
2345
+ async fetchAndHandleExternalTriggerRun(runId, dispatchEventId) {
2028
2346
  let run = null;
2029
2347
  try {
2030
2348
  run = await this.opts.client.getExternalTriggerRun(this.opts.config.org_id, runId);
@@ -2040,14 +2358,14 @@ export class ParallAgentGateway {
2040
2358
  }
2041
2359
  if (!run)
2042
2360
  return true;
2043
- return this.handleExternalTriggerRun(run);
2361
+ return this.handleExternalTriggerRun(run, dispatchEventId);
2044
2362
  }
2045
2363
  // fetchAndHandleChannelMessage resolves a channel_message dispatch to its
2046
2364
  // durable ChannelMessage + conversation and hands it to the inbound
2047
2365
  // pipeline. targetId = the ChannelConversation id, so per-conversation
2048
2366
  // multi-turn continuity rides the same per-target session mechanics as
2049
2367
  // chats. Design: docs/engineering-design/external-im-channel-design.md.
2050
- async fetchAndHandleChannelMessage(messageId) {
2368
+ async fetchAndHandleChannelMessage(messageId, dispatchEventId) {
2051
2369
  if (this.shuttingDown)
2052
2370
  return false;
2053
2371
  // Capped dedupe (the chat-message path, not the unbounded task set): a busy
@@ -2092,18 +2410,20 @@ export class ParallAgentGateway {
2092
2410
  provider = undefined; // label degrades; reply hint still names the clip generically
2093
2411
  }
2094
2412
  }
2095
- // The reply hint routes on the live capability grant: `<provider>-cli`
2096
- // present the vendor CLI is on PATH (broker shim) and is THE reply
2097
- // path; absent outbound is disabled for this org (flag/connection off)
2098
- // and the hint must say so instead of pointing at a retired clip. The
2099
- // provider label lookup above is best-effort/cosmetic when it fails,
2100
- // ANY granted `*-cli` capability keeps the hint on the CLI path: a
2101
- // transient metadata miss must not flip an actively granted agent's
2102
- // hint to "outbound disabled" and strand a valid external message.
2413
+ // The reply hint routes on the live capability grant, keyed PER
2414
+ // PROVIDER: feishu's affordance is the vendor CLI on PATH (`feishu-cli`
2415
+ // lark-cli, tier A) and slack's is the platform verb (`slack-send`
2416
+ // `parall slack send`, tier B there is no `slack-cli`). Absent
2417
+ // outbound is disabled for this org (flag/connection off) and the hint
2418
+ // must say so instead of pointing at a retired clip. The provider label
2419
+ // lookup above is best-effort/cosmetic when it fails, ANY granted
2420
+ // channel capability keeps the hint on the capability path: a transient
2421
+ // metadata miss must not flip an actively granted agent's hint to
2422
+ // "outbound disabled" and strand a valid external message.
2103
2423
  const keys = this.opts.getCapabilityKeys?.() ?? [];
2104
2424
  const cliCapable = provider
2105
- ? keys.includes(`${provider}-cli`)
2106
- : keys.some((k) => k.endsWith('-cli'));
2425
+ ? keys.includes(channelCapabilityKeyFor(provider))
2426
+ : keys.some((k) => k.endsWith('-cli') || k === CAPABILITY_SLACK_SEND);
2107
2427
  const event = {
2108
2428
  type: 'channel_message',
2109
2429
  targetId: conv.id,
@@ -2121,6 +2441,7 @@ export class ParallAgentGateway {
2121
2441
  channelCliCapable: cliCapable,
2122
2442
  ackSourceType: 'channel_message',
2123
2443
  ackSourceId: msg.id,
2444
+ dispatchEventId,
2124
2445
  };
2125
2446
  // Release the claim if the event isn't actually dispatched (or throws) so
2126
2447
  // a retry can re-attempt — same contract as the chat-message path.
@@ -2132,12 +2453,12 @@ export class ParallAgentGateway {
2132
2453
  this.dispatchedMessages.delete(claimKey);
2133
2454
  throw err;
2134
2455
  }
2135
- if (!dispatched) {
2456
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
2136
2457
  this.dispatchedMessages.delete(claimKey);
2137
2458
  }
2138
2459
  return dispatched;
2139
2460
  }
2140
- async handleExternalTriggerRun(run) {
2461
+ async handleExternalTriggerRun(run, dispatchEventId) {
2141
2462
  if (this.shuttingDown)
2142
2463
  return false;
2143
2464
  const dedupeKey = `external_trigger_run:${run.id}`;
@@ -2165,6 +2486,7 @@ export class ParallAgentGateway {
2165
2486
  attachedUri,
2166
2487
  ackSourceType: 'external_trigger_run',
2167
2488
  ackSourceId: run.id,
2489
+ dispatchEventId,
2168
2490
  };
2169
2491
  let dispatched;
2170
2492
  try {
@@ -2174,12 +2496,12 @@ export class ParallAgentGateway {
2174
2496
  this.dispatchedTasks.delete(dedupeKey);
2175
2497
  throw err;
2176
2498
  }
2177
- if (!dispatched) {
2499
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
2178
2500
  this.dispatchedTasks.delete(dedupeKey);
2179
2501
  }
2180
2502
  return dispatched;
2181
2503
  }
2182
- async fetchAndHandleApprovalDecided(approvalId, actorId, chatId) {
2504
+ async fetchAndHandleApprovalDecided(approvalId, actorId, chatId, dispatchEventId) {
2183
2505
  let approval = null;
2184
2506
  try {
2185
2507
  approval = await this.opts.client.getApproval(approvalId);
@@ -2212,6 +2534,11 @@ export class ParallAgentGateway {
2212
2534
  senderName: 'approver',
2213
2535
  messageId: approval.id,
2214
2536
  body,
2537
+ // The WorkItem's real source pair. Without it the legacy fallback
2538
+ // guessed ('message', approval_id) — a pair no row matches.
2539
+ ackSourceType: 'approval',
2540
+ ackSourceId: approval.id,
2541
+ dispatchEventId,
2215
2542
  };
2216
2543
  let dispatched;
2217
2544
  try {
@@ -2221,7 +2548,7 @@ export class ParallAgentGateway {
2221
2548
  this.dispatchedTasks.delete(dedupeKey);
2222
2549
  throw err;
2223
2550
  }
2224
- if (!dispatched) {
2551
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
2225
2552
  this.dispatchedTasks.delete(dedupeKey);
2226
2553
  }
2227
2554
  return dispatched;
@@ -2262,7 +2589,26 @@ export class ParallAgentGateway {
2262
2589
  break;
2263
2590
  if (overflowMode && processed >= CATCHUP_MAX) {
2264
2591
  try {
2265
- await this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id);
2592
+ // Administrative drop of the overflow backlog (summarized to the
2593
+ // agent instead of dispatched). Ledger path: by-id complete —
2594
+ // closes the pending row and refuses (stale) one a live lane
2595
+ // owns; the legacy ack stays as the ledger-disabled / pre-by-id
2596
+ // fallback.
2597
+ if (this.laneLedger && !this.ledgerDisabled && !this.typedByIdCompleteUnsupported) {
2598
+ const outcome = await this.resolveDispatchByID(item.id);
2599
+ if (outcome === 'unsupported') {
2600
+ this.typedByIdCompleteUnsupported = true;
2601
+ await this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id);
2602
+ }
2603
+ else if (outcome === 'failed') {
2604
+ throw new Error('by-id complete failed');
2605
+ }
2606
+ // 'stale' counts as skipped: a live lane owns the row and its
2607
+ // turn will resolve it — nothing left for catch-up to do.
2608
+ }
2609
+ else {
2610
+ await this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id);
2611
+ }
2266
2612
  skipped++;
2267
2613
  const key = item.event_type;
2268
2614
  skippedByType.set(key, (skippedByType.get(key) ?? 0) + 1);
@@ -2272,14 +2618,24 @@ export class ParallAgentGateway {
2272
2618
  }
2273
2619
  continue;
2274
2620
  }
2621
+ // Same pre-claim guard as the dispatch.new handler: a WorkItem whose
2622
+ // event copy is already buffered belongs to the drain — claiming it
2623
+ // here would race the drain's fence-less settlement.
2624
+ if (item.event_type !== 'message' && this.isBufferedTypedWorkItem(item.id)) {
2625
+ this.opts.log?.info(`typed dispatch ${item.id} already buffered for the drain — skipping catch-up claim`);
2626
+ continue;
2627
+ }
2275
2628
  processed++;
2276
2629
  try {
2277
- const ackItem = () => this.ackDispatchEvent(item.id, () => this.clearTypedDispatchDedupe(item));
2630
+ const typedHooks = {
2631
+ legacyAck: () => this.ackDispatchEvent(item.id, () => this.clearTypedDispatchDedupe(item)),
2632
+ clearDedupe: () => this.clearTypedDispatchDedupe(item),
2633
+ };
2278
2634
  if (item.event_type === 'task_assign' && item.task_id) {
2279
2635
  try {
2280
2636
  await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.handleTaskDispatch(item.task_id ?? '', item.source_id ?? item.task_id ?? '', {
2281
2637
  dispatchEventId,
2282
- }), ackItem);
2638
+ }), typedHooks);
2283
2639
  }
2284
2640
  catch (err) {
2285
2641
  this.opts.log?.warn(`catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
@@ -2291,7 +2647,7 @@ export class ParallAgentGateway {
2291
2647
  await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.handleTaskDispatch(item.task_id ?? '', item.source_id ?? item.task_id ?? '', {
2292
2648
  allowCreator: true,
2293
2649
  dispatchEventId,
2294
- }), ackItem);
2650
+ }), typedHooks);
2295
2651
  }
2296
2652
  catch (err) {
2297
2653
  this.opts.log?.warn(`catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
@@ -2299,22 +2655,22 @@ export class ParallAgentGateway {
2299
2655
  }
2300
2656
  }
2301
2657
  else if (item.event_type === 'task_comment' && item.source_id && item.task_id) {
2302
- await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.handleTaskComment(item.source_id, item.task_id ?? '', item.actor_id, item.delivery_reason), ackItem);
2658
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.handleTaskComment(item.source_id, item.task_id ?? '', item.actor_id, item.delivery_reason, dispatchEventId), typedHooks);
2303
2659
  }
2304
2660
  else if (item.event_type === 'wiki_comment' && item.source_id) {
2305
- await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.handleWikiComment(item.source_id, item.actor_id, item.delivery_reason), ackItem);
2661
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.handleWikiComment(item.source_id, item.actor_id, item.delivery_reason, dispatchEventId), typedHooks);
2306
2662
  }
2307
2663
  else if (item.event_type === 'schedule.fire' && item.source_id) {
2308
- await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleScheduleFire(item.source_id, item.actor_id), ackItem);
2664
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.fetchAndHandleScheduleFire(item.source_id, item.actor_id, dispatchEventId), typedHooks);
2309
2665
  }
2310
2666
  else if (item.event_type === 'external_trigger' && item.source_id) {
2311
- await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleExternalTriggerRun(item.source_id), ackItem);
2667
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.fetchAndHandleExternalTriggerRun(item.source_id, dispatchEventId), typedHooks);
2312
2668
  }
2313
2669
  else if (item.event_type === 'channel_message' && item.source_id) {
2314
- await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleChannelMessage(item.source_id), ackItem);
2670
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.fetchAndHandleChannelMessage(item.source_id, dispatchEventId), typedHooks);
2315
2671
  }
2316
2672
  else if (item.event_type === 'approval_decided' && item.source_id) {
2317
- await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleApprovalDecided(item.source_id, item.actor_id, item.chat_id ?? null), ackItem);
2673
+ await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.fetchAndHandleApprovalDecided(item.source_id, item.actor_id, item.chat_id ?? null, dispatchEventId), typedHooks);
2318
2674
  }
2319
2675
  else if (item.event_type === 'message' && item.source_id && item.chat_id) {
2320
2676
  await this.consumeMessageWorkItem({
@@ -2491,6 +2847,27 @@ export class ParallAgentGateway {
2491
2847
  await this.laneLedger.releaseAll();
2492
2848
  }
2493
2849
  await this.opts.onBeforeDisconnect?.();
2850
+ // Parked step writes are process-local and their WorkItems are already
2851
+ // resolved — restart catch-up will NOT re-drive them, so anything still
2852
+ // parked at exit is permanently lost. Spend a slice of the shutdown
2853
+ // budget on one flush pass first: the common shutdown (idle-stop,
2854
+ // deploy) happens on a healthy network where these writes just succeed.
2855
+ // The 10s cap is hard — a write still in flight at the deadline is
2856
+ // abandoned to the background (see StepRetryQueue.flush).
2857
+ if (this.stepPersister.pendingTotal() > 0) {
2858
+ const remaining = await this.stepPersister.flush(10_000);
2859
+ if (remaining > 0) {
2860
+ this.opts.log?.warn(`${remaining} parked step write(s) could not be flushed at shutdown; they are permanently lost`);
2861
+ }
2862
+ }
2863
+ this.stepPersister.dispose();
2864
+ // Lifecycle last: idle writes land after the flushed steps, so the
2865
+ // server clears activity once and no flushed step can relight it.
2866
+ const lifecycleRemaining = await this.sessionLifecycle.flush(5_000);
2867
+ if (lifecycleRemaining > 0) {
2868
+ this.opts.log?.warn(`${lifecycleRemaining} session lifecycle write(s) unreconciled at shutdown`);
2869
+ }
2870
+ this.sessionLifecycle.dispose();
2494
2871
  this.opts.ws.disconnect();
2495
2872
  this.opts.log?.info(`disconnected`);
2496
2873
  }