@parall/agent-core 1.45.0 → 1.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/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 +47 -0
  12. package/dist/gateway-base.d.ts.map +1 -1
  13. package/dist/gateway-base.js +457 -200
  14. package/dist/gateway-lane-flow.d.ts.map +1 -1
  15. package/dist/gateway-lane-flow.js +13 -7
  16. package/dist/http-keepalive.d.ts +4 -0
  17. package/dist/http-keepalive.d.ts.map +1 -0
  18. package/dist/http-keepalive.js +33 -0
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +6 -0
  22. package/dist/session-lifecycle.d.ts +198 -0
  23. package/dist/session-lifecycle.d.ts.map +1 -0
  24. package/dist/session-lifecycle.js +446 -0
  25. package/dist/skills/parall-clips.d.ts +1 -1
  26. package/dist/skills/parall-clips.d.ts.map +1 -1
  27. package/dist/skills/parall-clips.js +3 -0
  28. package/dist/step-persister.d.ts +66 -0
  29. package/dist/step-persister.d.ts.map +1 -0
  30. package/dist/step-persister.js +116 -0
  31. package/dist/step-retry-queue.d.ts +91 -0
  32. package/dist/step-retry-queue.d.ts.map +1 -0
  33. package/dist/step-retry-queue.js +259 -0
  34. package/package.json +3 -2
  35. package/src/channel-capability.ts +16 -0
  36. package/src/dispatch-adapter.ts +10 -0
  37. package/src/event-format.ts +27 -7
  38. package/src/fork-session-finalizer.ts +122 -0
  39. package/src/gateway-base.ts +487 -255
  40. package/src/gateway-lane-flow.ts +12 -7
  41. package/src/http-keepalive.ts +36 -0
  42. package/src/index.ts +6 -0
  43. package/src/session-lifecycle.ts +552 -0
  44. package/src/skills/parall-clips.ts +3 -0
  45. package/src/step-persister.ts +161 -0
  46. package/src/step-retry-queue.ts +296 -0
@@ -1,6 +1,7 @@
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, ParallClient, ParallWs } from '@parall/sdk';
5
6
  import type {
6
7
  AgentConfigUpdateData,
@@ -28,6 +29,7 @@ import {
28
29
  buildForkResultPrefix,
29
30
  buildForkScopePrefix,
30
31
  } from './event-format.js';
32
+ import { CAPABILITY_SLACK_SEND, channelCapabilityKeyFor } from './channel-capability.js';
31
33
  import {
32
34
  buildErrorStepContent,
33
35
  type CleanupForkOpts,
@@ -38,6 +40,7 @@ import {
38
40
  type RuntimeEvent,
39
41
  } from './dispatch-adapter.js';
40
42
  import {
43
+ clearTypedDedupeForEvent,
41
44
  consumeMessageWorkItem,
42
45
  consumeTypedDispatch,
43
46
  dispatchLaneGroup,
@@ -48,6 +51,9 @@ import {
48
51
  import type { LaneFlowHost, TypedConsumeHooks } from './gateway-lane-flow.js';
49
52
  import { LaneLedger } from './lane-ledger.js';
50
53
  import { routeTrigger } from './routing.js';
54
+ import { StepPersister, isRetryableStepError } from './step-persister.js';
55
+ import { SessionLifecycleCoordinator, type TurnHandle } from './session-lifecycle.js';
56
+ import { ForkSessionFinalizer } from './fork-session-finalizer.js';
51
57
  import {
52
58
  clearDispatchMessageId,
53
59
  clearDispatchMetrics,
@@ -141,6 +147,10 @@ export type ParallGatewayOptions = {
141
147
  shutdownDeadlineMs?: number;
142
148
  forkDeadlineMs?: number;
143
149
  dispatchDeadlineMs?: number;
150
+ /** Test port: step retry-queue backoff override (StepRetryQueue schedule). */
151
+ stepRetryDelaysMs?: number[];
152
+ /** Test port: session lifecycle reconcile backoff override. */
153
+ lifecycleRetryDelaysMs?: number[];
144
154
  contextFilePathForSession?: (sessionKey: string) => string | undefined;
145
155
  /** @deprecated Use contextFilePathForSession. Kept for runtimes that haven't migrated. */
146
156
  stepIdFilePathForSession?: (sessionKey: string) => string | undefined;
@@ -213,6 +223,36 @@ export function parseDispatchDeadlineMs(raw: string | undefined): number | undef
213
223
  return Math.floor(n);
214
224
  }
215
225
 
226
+ /**
227
+ * Stable input-step idempotency key: a re-delivered event (catch-up replay,
228
+ * lane re-drive) replays to the same row instead of duplicating it.
229
+ *
230
+ * Message-shaped events key on the message id — it is the logical identity
231
+ * and stays stable across delivery paths (a live WS delivery carries no
232
+ * dispatchEventId; its catch-up replay does — keying on the WorkItem there
233
+ * would split the two into different keys and duplicate the input step).
234
+ *
235
+ * Typed events key on the WorkItem id instead: their messageId can be
236
+ * REUSED across distinct work items — task events carry the task id for
237
+ * both task_assign and a later task_update, so keying on messageId would
238
+ * silently dedupe the second, legitimate, input step away. For the same
239
+ * reason a typed event WITHOUT a WorkItem id (legacy server) must NOT fall
240
+ * back to messageId — it gets a random UUID per logical step instead. The
241
+ * key rides the CreateAgentStepRequest object, so the same UUID is reused
242
+ * across that step's HTTP retries and queue redrives (replay-safe), while
243
+ * a redelivered legacy event writes a fresh row (rare duplicate beats
244
+ * silently losing a legitimate step). See protocol-vectors/agent-steps.json.
245
+ */
246
+ export function inputStepIdempotencyKey(
247
+ event: Pick<ParallEvent, 'type' | 'dispatchEventId' | 'messageId'>,
248
+ ): string {
249
+ if (event.type === 'message' || event.type === 'channel_message') {
250
+ return event.messageId ? `input:${event.messageId}` : randomUUID();
251
+ }
252
+ if (event.dispatchEventId) return `input:${event.dispatchEventId}`;
253
+ return randomUUID();
254
+ }
255
+
216
256
  function resolveStepTarget(event: ParallEvent): { target_type: string; target_id?: string } {
217
257
  if (event.type === 'task' || event.targetId.startsWith('tsk_')) {
218
258
  return { target_type: 'task', target_id: event.targetId };
@@ -322,6 +362,13 @@ export class ParallAgentGateway {
322
362
 
323
363
  private lastHeartbeatAt = Date.now();
324
364
  private draining = false;
365
+ /**
366
+ * Typed WorkItem ids whose drain group left the buffer but has not settled
367
+ * yet. isBufferedTypedWorkItem treats them as still buffered — a re-drive
368
+ * claim taken inside this window would turn the drain's fence-less close
369
+ * stale (its dedupe cleared, its release re-driving handled work) (#1149).
370
+ */
371
+ private drainingTypedIds = new Set<string>();
325
372
 
326
373
  // Graceful shutdown state. When SIGTERM / abort fires, `shuttingDown` flips
327
374
  // to true so no new dispatches start, and `inFlightDispatches` counts runs
@@ -333,6 +380,16 @@ export class ParallAgentGateway {
333
380
  private pendingRestartNotification: string | null = null;
334
381
 
335
382
  private readonly laneLedger?: LaneLedger;
383
+ private readonly stepPersister: StepPersister;
384
+ // Single entry point for session active/idle writes — serialized
385
+ // desired-state reconciler (see session-lifecycle.ts). The gateway only
386
+ // declares turn boundaries; ordering, retries and stale-finish rejection
387
+ // live in the coordinator.
388
+ private readonly sessionLifecycle: SessionLifecycleCoordinator;
389
+ // Normal fork teardown use case: seal → drain → close → release. The
390
+ // gateway only triggers it; ordering and ownership live in the finalizer
391
+ // (see fork-session-finalizer.ts).
392
+ private readonly forkFinalizer: ForkSessionFinalizer;
336
393
  // Sticky fallback: flipped when the server predates the ledger (claim
337
394
  // endpoint 404) so every subsequent dispatch uses the legacy flow.
338
395
  private ledgerDisabled = false;
@@ -361,6 +418,35 @@ export class ParallAgentGateway {
361
418
  this.SHUTDOWN_DEADLINE_MS = opts.shutdownDeadlineMs ?? 60_000;
362
419
  this.FORK_DEADLINE_MS = opts.forkDeadlineMs ?? 2 * 60 * 60_000;
363
420
  this.DISPATCH_DEADLINE_MS = opts.dispatchDeadlineMs ?? 20 * 60_000;
421
+ this.stepPersister = new StepPersister({
422
+ client: opts.client,
423
+ orgId: opts.config.org_id,
424
+ agentUserId: opts.agentUserId,
425
+ log: { warn: (msg) => this.opts.log?.warn(msg) },
426
+ isSessionStale: (err) => this.isSessionNotLiveError(err),
427
+ retryDelaysMs: opts.stepRetryDelaysMs,
428
+ });
429
+ this.sessionLifecycle = new SessionLifecycleCoordinator({
430
+ write: (sessionId, payload) =>
431
+ this.opts.client
432
+ .updateAgentSession(this.opts.config.org_id, this.opts.agentUserId, sessionId, payload)
433
+ .then(() => undefined),
434
+ // Same classification as step writes: transient (timeout/network/5xx)
435
+ // retries with backoff; a 4xx is permanent (never loop against it);
436
+ // a terminal-session 409 drops the session for good.
437
+ isRetryable: isRetryableStepError,
438
+ isSessionStale: (err) => this.isSessionNotLiveError(err),
439
+ log: { warn: (msg) => this.opts.log?.warn(msg) },
440
+ retryDelaysMs: opts.lifecycleRetryDelaysMs,
441
+ });
442
+ this.forkFinalizer = new ForkSessionFinalizer({
443
+ steps: this.stepPersister,
444
+ lifecycle: this.sessionLifecycle,
445
+ log: {
446
+ warn: (msg) => this.opts.log?.warn(msg),
447
+ error: (msg) => this.opts.log?.error(msg),
448
+ },
449
+ });
364
450
  if (opts.coldStartWindowMs != null) {
365
451
  opts.log?.warn?.(
366
452
  'coldStartWindowMs is deprecated and ignored — cold-start time filter has been removed',
@@ -420,6 +506,14 @@ export class ParallAgentGateway {
420
506
  const prevId = data.previous_session_id ?? '';
421
507
  this.opts.log?.info(`new session signal received (previous=${prevId})`);
422
508
  this.sessionBindings.clear();
509
+ // The server closed EVERY open session — not just `previous_session_id`
510
+ // (active forks have their own ase_ ids, and sessionBindings was just
511
+ // cleared, so they would otherwise keep retrying against closed rows
512
+ // until a 409 or the age budget). Any parked lifecycle write or step
513
+ // write would now 409 against a terminal row: drop them all rather than
514
+ // burn retry budget and log noise discovering it.
515
+ this.sessionLifecycle.dropAllSessions();
516
+ this.stepPersister.dropAllSessions();
423
517
  if (prevId) {
424
518
  this.pendingRestartNotification = `[Harness Notification] This is a fresh session. Your previous session (${prevId}) was ended by the user and you have been restarted.`;
425
519
  }
@@ -485,6 +579,15 @@ export class ParallAgentGateway {
485
579
  });
486
580
 
487
581
  ws.on('dispatch.new', async (data: DispatchNewData) => {
582
+ // Re-drive of a WorkItem whose event copy is already buffered: skip
583
+ // BEFORE the typed claim (see isBufferedTypedWorkItem). The renotify
584
+ // pacing re-checks after the drain settles.
585
+ if (data.event_type !== 'message' && this.isBufferedTypedWorkItem(data.id)) {
586
+ this.opts.log?.info(
587
+ `typed dispatch ${data.id} already buffered for the drain — skipping re-claim`,
588
+ );
589
+ return;
590
+ }
488
591
  if (data.event_type === 'task_assign') {
489
592
  if (!data.task_id) return;
490
593
  try {
@@ -780,7 +883,14 @@ export class ParallAgentGateway {
780
883
  // into one turn.
781
884
  return this.laneLedger!.laneKeyFor(event);
782
885
  }
783
- return event.targetId;
886
+ // Typed events never share a drain group with messages. On the
887
+ // ledger-disabled fallback both group by target id, and a typed event
888
+ // whose target is a chat (approval) could batch behind that chat's
889
+ // messages — the drain body carries only the trailing message while the
890
+ // legacy ack sweeps the whole group, silently dropping the typed body
891
+ // (typed events are never steer-injected, so the drain body is their
892
+ // only route to the model). Split the groups instead (#1149).
893
+ return event.type === 'message' ? event.targetId : `typed:${event.targetId}`;
784
894
  }
785
895
 
786
896
  // Lane-flow protocols live in gateway-lane-flow.ts; these thin delegates
@@ -831,6 +941,28 @@ export class ParallAgentGateway {
831
941
  );
832
942
  }
833
943
 
944
+ /**
945
+ * True while a typed WorkItem's event copy sits in the main buffer waiting
946
+ * for the drain. A buffered copy keeps its hot-path dedupe claim: the drain
947
+ * owns its settlement, and the released row's re-drive must short-circuit
948
+ * BEFORE claiming (a duplicate claim opens a lane that races the drain's
949
+ * fence-less by-id close — 409 STALE, and the claimant's release would
950
+ * re-drive already-handled work) (#1149). The claim's lifetime equals the
951
+ * buffer stay: settlement clears it on every drain outcome — success,
952
+ * failure, or a thrown turn.
953
+ */
954
+ private isBufferedTypedWorkItem(dispatchEventId: string | undefined | null): boolean {
955
+ if (!dispatchEventId) return false;
956
+ return (
957
+ this.drainingTypedIds.has(dispatchEventId) ||
958
+ this.dispatchState.mainBuffer.some((e) => e.dispatchEventId === dispatchEventId)
959
+ );
960
+ }
961
+
962
+ private isTypedEventBuffered(event: ParallEvent): boolean {
963
+ return this.isBufferedTypedWorkItem(event.dispatchEventId);
964
+ }
965
+
834
966
  // PARITY: this switch and gateway-lane-flow's clearTypedDedupeForEvent must
835
967
  // handle the same typed source families — extend BOTH when adding a typed
836
968
  // event type (same dedupe entries, keyed from different event shapes).
@@ -896,69 +1028,60 @@ export class ParallAgentGateway {
896
1028
 
897
1029
  private async createInputStep(sessionId: string, event: ParallEvent) {
898
1030
  const target = resolveStepTarget(event);
899
- try {
900
- await this.opts.client.createAgentStep(
901
- this.opts.config.org_id,
902
- this.opts.agentUserId,
903
- sessionId,
904
- {
905
- step_type: 'input',
906
- target_type: target.target_type,
907
- target_id: target.target_id,
908
- content: {
909
- trigger_type:
910
- event.type === 'task'
911
- ? 'task_assign'
912
- : event.type === 'task_comment'
913
- ? 'task_comment'
914
- : event.type === 'wiki_comment'
915
- ? 'wiki_comment'
916
- : event.type === 'schedule'
917
- ? 'schedule_fire'
918
- : event.type === 'external_trigger'
919
- ? 'external_trigger'
920
- : event.type === 'channel_message'
921
- ? 'channel_message'
922
- : event.type === 'approval'
923
- ? 'approval_decided'
924
- : 'mention',
925
- trigger_ref:
926
- event.type === 'task'
927
- ? { task_id: event.targetId }
928
- : event.type === 'task_comment'
929
- ? { comment_id: event.messageId, task_id: event.targetId }
930
- : event.type === 'wiki_comment'
931
- ? { comment_id: event.messageId, target_uri: event.replyTargetUri }
932
- : event.type === 'schedule'
933
- ? { schedule_id: event.targetId, run_id: event.messageId }
934
- : event.type === 'external_trigger'
935
- ? {
936
- trigger_id: event.targetId,
937
- run_id: event.messageId,
938
- connection_id: event.externalConnectionId,
939
- ingress_event_id: event.externalIngressEventId,
940
- }
941
- : event.type === 'channel_message'
942
- ? {
943
- conversation_id: event.targetId,
944
- channel_message_id: event.messageId,
945
- provider: event.channelProvider,
946
- external_conversation_id: event.channelExternalConversationId,
947
- }
948
- : event.type === 'approval'
949
- ? { approval_id: event.messageId }
950
- : { message_id: event.messageId },
951
- sender_id: event.senderId,
952
- sender_name: event.senderName,
953
- summary: event.body.substring(0, 200),
954
- ...(event.sentAt ? { sent_at: event.sentAt } : {}),
955
- },
956
- },
957
- );
958
- } catch (err) {
959
- if (this.isSessionNotLiveError(err)) throw err;
960
- this.opts.log?.warn(`failed to create input step: ${String(err)}`);
961
- }
1031
+ await this.stepPersister.persist(sessionId, 'input', {
1032
+ step_type: 'input',
1033
+ target_type: target.target_type,
1034
+ target_id: target.target_id,
1035
+ idempotency_key: inputStepIdempotencyKey(event),
1036
+ content: {
1037
+ trigger_type:
1038
+ event.type === 'task'
1039
+ ? 'task_assign'
1040
+ : event.type === 'task_comment'
1041
+ ? 'task_comment'
1042
+ : event.type === 'wiki_comment'
1043
+ ? 'wiki_comment'
1044
+ : event.type === 'schedule'
1045
+ ? 'schedule_fire'
1046
+ : event.type === 'external_trigger'
1047
+ ? 'external_trigger'
1048
+ : event.type === 'channel_message'
1049
+ ? 'channel_message'
1050
+ : event.type === 'approval'
1051
+ ? 'approval_decided'
1052
+ : 'mention',
1053
+ trigger_ref:
1054
+ event.type === 'task'
1055
+ ? { task_id: event.targetId }
1056
+ : event.type === 'task_comment'
1057
+ ? { comment_id: event.messageId, task_id: event.targetId }
1058
+ : event.type === 'wiki_comment'
1059
+ ? { comment_id: event.messageId, target_uri: event.replyTargetUri }
1060
+ : event.type === 'schedule'
1061
+ ? { schedule_id: event.targetId, run_id: event.messageId }
1062
+ : event.type === 'external_trigger'
1063
+ ? {
1064
+ trigger_id: event.targetId,
1065
+ run_id: event.messageId,
1066
+ connection_id: event.externalConnectionId,
1067
+ ingress_event_id: event.externalIngressEventId,
1068
+ }
1069
+ : event.type === 'channel_message'
1070
+ ? {
1071
+ conversation_id: event.targetId,
1072
+ channel_message_id: event.messageId,
1073
+ provider: event.channelProvider,
1074
+ external_conversation_id: event.channelExternalConversationId,
1075
+ }
1076
+ : event.type === 'approval'
1077
+ ? { approval_id: event.messageId }
1078
+ : { message_id: event.messageId },
1079
+ sender_id: event.senderId,
1080
+ sender_name: event.senderName,
1081
+ summary: event.body.substring(0, 200),
1082
+ ...(event.sentAt ? { sent_at: event.sentAt } : {}),
1083
+ },
1084
+ });
962
1085
  }
963
1086
 
964
1087
  private async createRuntimeStep(
@@ -970,62 +1093,57 @@ export class ParallAgentGateway {
970
1093
  laneContextFilePath?: string,
971
1094
  ) {
972
1095
  const target = resolveStepTarget(event);
973
- try {
974
- switch (runtimeEvent.type) {
975
- case 'thinking':
976
- await this.opts.client.createAgentStep(
977
- this.opts.config.org_id,
978
- this.opts.agentUserId,
979
- sessionId,
980
- {
981
- step_type: 'thinking',
982
- target_type: target.target_type,
983
- target_id: target.target_id,
984
- content: { text: runtimeEvent.text },
985
- group_key: runtimeEvent.groupKey,
986
- },
987
- );
988
- break;
1096
+ switch (runtimeEvent.type) {
1097
+ case 'thinking':
1098
+ await this.stepPersister.persist(sessionId, 'thinking', {
1099
+ step_type: 'thinking',
1100
+ target_type: target.target_type,
1101
+ target_id: target.target_id,
1102
+ idempotency_key: randomUUID(),
1103
+ content: { text: runtimeEvent.text },
1104
+ group_key: runtimeEvent.groupKey,
1105
+ });
1106
+ break;
989
1107
 
990
- case 'text':
991
- await this.opts.client.createAgentStep(
992
- this.opts.config.org_id,
993
- this.opts.agentUserId,
994
- sessionId,
995
- {
996
- step_type: 'text',
997
- target_type: target.target_type,
998
- target_id: target.target_id,
999
- content: {
1000
- text: runtimeEvent.text,
1001
- suppressed: runtimeEvent.project !== true,
1002
- },
1003
- projection: runtimeEvent.project === true,
1004
- group_key: runtimeEvent.groupKey,
1005
- },
1006
- );
1007
- break;
1108
+ case 'text':
1109
+ await this.stepPersister.persist(sessionId, 'text', {
1110
+ step_type: 'text',
1111
+ target_type: target.target_type,
1112
+ target_id: target.target_id,
1113
+ idempotency_key: randomUUID(),
1114
+ content: {
1115
+ text: runtimeEvent.text,
1116
+ suppressed: runtimeEvent.project !== true,
1117
+ },
1118
+ projection: runtimeEvent.project === true,
1119
+ group_key: runtimeEvent.groupKey,
1120
+ });
1121
+ break;
1008
1122
 
1009
- case 'tool_call': {
1010
- const step = await this.opts.client.createAgentStep(
1011
- this.opts.config.org_id,
1012
- this.opts.agentUserId,
1013
- sessionId,
1014
- {
1015
- step_type: 'tool_call',
1016
- target_type: target.target_type,
1017
- target_id: target.target_id,
1018
- content: {
1019
- call_id: runtimeEvent.callId,
1020
- tool_name: runtimeEvent.toolName,
1021
- tool_input: runtimeEvent.input,
1022
- status: 'running',
1023
- started_at: runtimeEvent.startedAt ?? new Date().toISOString(),
1024
- },
1025
- group_key: runtimeEvent.groupKey,
1026
- runtime_key: runtimeEvent.callId,
1027
- },
1028
- );
1123
+ case 'tool_call': {
1124
+ const step = await this.stepPersister.persist(sessionId, 'tool_call', {
1125
+ step_type: 'tool_call',
1126
+ target_type: target.target_type,
1127
+ target_id: target.target_id,
1128
+ // call_id is session-unique for bridge runtimes (server-enforced),
1129
+ // so the bare form anchors the tool step pair across retries —
1130
+ // unlike parel's turn-scoped `tc:{turnId}:{callId}` (see
1131
+ // protocol-vectors/agent-steps.json).
1132
+ idempotency_key: `tc:${runtimeEvent.callId}`,
1133
+ content: {
1134
+ call_id: runtimeEvent.callId,
1135
+ tool_name: runtimeEvent.toolName,
1136
+ tool_input: runtimeEvent.input,
1137
+ status: 'running',
1138
+ started_at: runtimeEvent.startedAt ?? new Date().toISOString(),
1139
+ },
1140
+ group_key: runtimeEvent.groupKey,
1141
+ runtime_key: runtimeEvent.callId,
1142
+ });
1143
+ // step is null when the write was queued for background retry — the
1144
+ // CLI step-id linkage window has then passed, same as a failed write
1145
+ // before the queue existed.
1146
+ if (step) {
1029
1147
  if (contextFilePath) {
1030
1148
  this.updateContextFileStepId(contextFilePath, step.id);
1031
1149
  } else if (stepIdFilePath) {
@@ -1034,57 +1152,48 @@ export class ParallAgentGateway {
1034
1152
  if (laneContextFilePath) {
1035
1153
  this.updateContextFileStepId(laneContextFilePath, step.id);
1036
1154
  }
1037
- break;
1038
1155
  }
1156
+ break;
1157
+ }
1039
1158
 
1040
- case 'tool_result':
1041
- await this.opts.client.createAgentStep(
1042
- this.opts.config.org_id,
1043
- this.opts.agentUserId,
1044
- sessionId,
1045
- {
1046
- step_type: 'tool_result',
1047
- target_type: target.target_type,
1048
- target_id: target.target_id,
1049
- content: {
1050
- call_id: runtimeEvent.callId,
1051
- tool_name: runtimeEvent.toolName,
1052
- status: runtimeEvent.error ? 'error' : 'success',
1053
- output: runtimeEvent.output,
1054
- duration_ms: runtimeEvent.durationMs ?? 0,
1055
- collapsible: true,
1056
- },
1057
- group_key: runtimeEvent.groupKey,
1058
- },
1059
- );
1060
- if (contextFilePath) {
1061
- this.updateContextFileStepId(contextFilePath, null);
1062
- } else if (stepIdFilePath) {
1063
- this.clearStepIdFile(stepIdFilePath);
1064
- }
1065
- if (laneContextFilePath) {
1066
- this.updateContextFileStepId(laneContextFilePath, null);
1067
- }
1068
- break;
1159
+ case 'tool_result':
1160
+ await this.stepPersister.persist(sessionId, 'tool_result', {
1161
+ step_type: 'tool_result',
1162
+ target_type: target.target_type,
1163
+ target_id: target.target_id,
1164
+ idempotency_key: `tr:${runtimeEvent.callId}`,
1165
+ content: {
1166
+ call_id: runtimeEvent.callId,
1167
+ tool_name: runtimeEvent.toolName,
1168
+ status: runtimeEvent.error ? 'error' : 'success',
1169
+ output: runtimeEvent.output,
1170
+ duration_ms: runtimeEvent.durationMs ?? 0,
1171
+ collapsible: true,
1172
+ },
1173
+ group_key: runtimeEvent.groupKey,
1174
+ });
1175
+ // The tool has finished regardless of whether the step write landed
1176
+ // inline or was queued — always clear the step-id linkage.
1177
+ if (contextFilePath) {
1178
+ this.updateContextFileStepId(contextFilePath, null);
1179
+ } else if (stepIdFilePath) {
1180
+ this.clearStepIdFile(stepIdFilePath);
1181
+ }
1182
+ if (laneContextFilePath) {
1183
+ this.updateContextFileStepId(laneContextFilePath, null);
1184
+ }
1185
+ break;
1069
1186
 
1070
- case 'error':
1071
- await this.opts.client.createAgentStep(
1072
- this.opts.config.org_id,
1073
- this.opts.agentUserId,
1074
- sessionId,
1075
- {
1076
- step_type: 'text',
1077
- target_type: target.target_type,
1078
- target_id: target.target_id,
1079
- content: buildErrorStepContent(runtimeEvent.message),
1080
- projection: false,
1081
- },
1082
- );
1083
- break;
1084
- }
1085
- } catch (err) {
1086
- if (this.isSessionNotLiveError(err)) throw err;
1087
- this.opts.log?.warn(`failed to create ${runtimeEvent.type} step: ${String(err)}`);
1187
+ case 'error':
1188
+ await this.stepPersister.persist(sessionId, 'error', {
1189
+ step_type: 'text',
1190
+ target_type: target.target_type,
1191
+ target_id: target.target_id,
1192
+ idempotency_key: randomUUID(),
1193
+ content: buildErrorStepContent(runtimeEvent.message),
1194
+ projection: false,
1195
+ });
1196
+ break;
1088
1197
  }
1089
1198
  }
1090
1199
 
@@ -1307,9 +1416,19 @@ export class ParallAgentGateway {
1307
1416
 
1308
1417
  let binding = this.sessionBindings.get(sessionKey);
1309
1418
  let inputStepsCreated = false;
1310
- let triggerMessageSet = false;
1419
+ let turnHandle: TurnHandle | undefined;
1311
1420
  let dispatchError: unknown;
1312
1421
  const pendingSendCallIds = new Set<string>();
1422
+ // Turn boundary: must complete one bounded active reconciliation
1423
+ // BEFORE this turn's first AgentStep persists — a reused idle session
1424
+ // otherwise races the status write and the server presence guard
1425
+ // swallows the turn's first activity. Resolves even when the write
1426
+ // fails (warned; the coordinator keeps reconciling in the background)
1427
+ // so a degraded link never blocks the step flow indefinitely.
1428
+ const ensureTurnBegun = async (): Promise<void> => {
1429
+ if (turnHandle || !binding) return;
1430
+ turnHandle = await this.sessionLifecycle.beginTurn(binding.agentSessionId, event.messageId);
1431
+ };
1313
1432
  try {
1314
1433
  dispatchSpan = startDispatchSpan(event, this.opts.runtimeType, sessionKey);
1315
1434
  for await (const runtimeEvent of this.opts.dispatchAdapter.dispatch({
@@ -1351,6 +1470,7 @@ export class ParallAgentGateway {
1351
1470
  // while a dispatch was in flight) inside the in-flight window so a
1352
1471
  // shutdown short-circuit BEFORE this point cannot leave orphan input
1353
1472
  // steps that the replacement pod would duplicate on replay.
1473
+ await ensureTurnBegun();
1354
1474
  if (earlierEvents.length > 0) {
1355
1475
  await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
1356
1476
  }
@@ -1364,17 +1484,7 @@ export class ParallAgentGateway {
1364
1484
  const detail = runtimeEvent.type === 'error' ? `: ${runtimeEvent.message}` : '';
1365
1485
  throw new Error(`runtime emitted ${runtimeEvent.type} before runtime_session${detail}`);
1366
1486
  }
1367
- if (!triggerMessageSet) {
1368
- triggerMessageSet = true;
1369
- this.opts.client
1370
- .updateAgentSession(
1371
- this.opts.config.org_id,
1372
- this.opts.agentUserId,
1373
- binding.agentSessionId,
1374
- { status: 'active', trigger_message_id: event.messageId },
1375
- )
1376
- .catch((err) => this.opts.log?.warn?.(`failed to set session active: ${err}`));
1377
- }
1487
+ await ensureTurnBegun();
1378
1488
  if (!inputStepsCreated) {
1379
1489
  if (earlierEvents.length > 0) {
1380
1490
  await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
@@ -1426,6 +1536,7 @@ export class ParallAgentGateway {
1426
1536
  throw new Error('runtime completed without runtime_session');
1427
1537
  }
1428
1538
  if (!inputStepsCreated) {
1539
+ await ensureTurnBegun();
1429
1540
  if (earlierEvents.length > 0) {
1430
1541
  await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
1431
1542
  }
@@ -1436,6 +1547,7 @@ export class ParallAgentGateway {
1436
1547
  let staleDetected = this.isSessionNotLiveError(err);
1437
1548
  if (!staleDetected && binding) {
1438
1549
  try {
1550
+ await ensureTurnBegun();
1439
1551
  await this.createRuntimeStep(
1440
1552
  binding.agentSessionId,
1441
1553
  event,
@@ -1456,6 +1568,8 @@ export class ParallAgentGateway {
1456
1568
  `session ${binding.agentSessionId} is stale (mid-dispatch), triggering recovery for ${sessionKey}`,
1457
1569
  );
1458
1570
  this.sessionBindings.delete(sessionKey);
1571
+ this.stepPersister.dropSession(binding.agentSessionId);
1572
+ this.sessionLifecycle.dropSession(binding.agentSessionId);
1459
1573
  if (sessionKey === this.opts.runtimeKey) {
1460
1574
  this.activeSessionId = undefined;
1461
1575
  }
@@ -1492,15 +1606,10 @@ export class ParallAgentGateway {
1492
1606
  }
1493
1607
 
1494
1608
  clearDispatchMetrics(sessionKey);
1495
- if (triggerMessageSet && binding) {
1496
- this.opts.client
1497
- .updateAgentSession(
1498
- this.opts.config.org_id,
1499
- this.opts.agentUserId,
1500
- binding.agentSessionId,
1501
- { status: 'idle' },
1502
- )
1503
- .catch((err) => this.opts.log?.warn?.(`failed to set session idle: ${err}`));
1609
+ if (turnHandle) {
1610
+ // Stale-handle safe: if a newer turn already began on this
1611
+ // session, the coordinator ignores this finish outright.
1612
+ this.sessionLifecycle.finishTurn(turnHandle);
1504
1613
  }
1505
1614
  clearSessionMessageId(sessionKey);
1506
1615
  clearDispatchMessageId(sessionKey);
@@ -1749,15 +1858,18 @@ export class ParallAgentGateway {
1749
1858
  }
1750
1859
  }
1751
1860
  if (forkBinding) {
1752
- this.opts.client
1753
- .updateAgentSession(
1754
- this.opts.config.org_id,
1755
- this.opts.agentUserId,
1756
- forkBinding.agentSessionId,
1757
- { status: 'closed' },
1758
- )
1759
- .catch(() => {});
1760
- this.sessionBindings.delete(fork.fork.sessionKey);
1861
+ // Normal fork teardown is the ForkSessionFinalizer use case:
1862
+ // seal → drain parked/in-flight step writes → close (serialized
1863
+ // behind the turn's idle, ownership held through close retries) →
1864
+ // release the binding. The gateway only triggers it. Notably this
1865
+ // must NOT drop the session's step queue — parked steps drain to
1866
+ // the server before the close is issued (the identity check on the
1867
+ // release keeps a replacement fork's binding intact).
1868
+ await this.forkFinalizer.finalize(forkBinding.agentSessionId, () => {
1869
+ if (this.sessionBindings.get(fork.fork.sessionKey) === forkBinding) {
1870
+ this.sessionBindings.delete(fork.fork.sessionKey);
1871
+ }
1872
+ });
1761
1873
  }
1762
1874
  }
1763
1875
  }
@@ -1844,55 +1956,110 @@ export class ParallAgentGateway {
1844
1956
  // false) and released the claims, so THIS site owns their resolution.
1845
1957
  // Message groups here are the ledger-disabled legacy flow.
1846
1958
  const typedRefs = this.typedLedgerEventIds(events);
1847
- if (!typedRefs) {
1959
+ // Guard the settlement window: from the moment the group leaves the
1960
+ // buffer until settlement finishes, a re-drive must still be
1961
+ // absorbed by isBufferedTypedWorkItem — see drainingTypedIds. Keyed
1962
+ // on the events' own WorkItem ids, NOT typedRefs: the legacy
1963
+ // (ledger-disabled) fallback settles through per-event acks but its
1964
+ // re-drives converge through the same buffered-WorkItem check.
1965
+ const drainingIds = events
1966
+ .map((ev) => ev.dispatchEventId)
1967
+ .filter((id): id is string => !!id);
1968
+ for (const id of drainingIds) this.drainingTypedIds.add(id);
1969
+ try {
1970
+ if (!typedRefs) {
1971
+ try {
1972
+ await this.emitDispatchReceived(event);
1973
+ } catch (err) {
1974
+ this.opts.log?.warn?.(
1975
+ `mark-received failed for buffered dispatch, leaving unacked for retry: ${String(err)}`,
1976
+ );
1977
+ this.dispatchState.mainBuffer.unshift(...events);
1978
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1979
+ break;
1980
+ }
1981
+ }
1982
+ // A typed group dispatches ALL buffered bodies, not just the
1983
+ // newest: typed events are never steer-injected (unlike messages,
1984
+ // which the model already saw mid-turn), so a comment burst folded
1985
+ // into one drain turn would otherwise surface only its last member
1986
+ // to the LLM — earlier ones exist solely as input steps the model
1987
+ // never reads. Keyed on the event kind, NOT on typedRefs: the
1988
+ // ledger-disabled fallback buffers the same bursts and owes the
1989
+ // model the same visibility (groups are homogeneous — see
1990
+ // dispatchGroupKey). Adapters that present earlierEvents natively
1991
+ // (OpenClaw InboundHistory) are exempt — concatenating would show
1992
+ // every earlier member twice.
1993
+ const isTypedGroup = events.every((ev) => ev.type !== 'message');
1994
+ const body =
1995
+ isTypedGroup &&
1996
+ events.length > 1 &&
1997
+ this.opts.dispatchAdapter.earlierEventsInPrompt !== true
1998
+ ? events.map((ev) => buildEventBody(ev)).join('\n\n')
1999
+ : buildEventBody(event);
2000
+ let dispatched: boolean;
1848
2001
  try {
1849
- await this.emitDispatchReceived(event);
2002
+ dispatched = await this.runDispatch(
2003
+ event,
2004
+ this.opts.runtimeKey,
2005
+ forkPrefix + body,
2006
+ earlier,
2007
+ );
1850
2008
  } catch (err) {
1851
- this.opts.log?.warn?.(
1852
- `mark-received failed for buffered dispatch, leaving unacked for retry: ${String(err)}`,
2009
+ if (!isTypedGroup) throw err;
2010
+ // The retained dedupe claims live exactly as long as the buffer
2011
+ // stay — a thrown turn dropped these events without settlement,
2012
+ // so free the claims here or every re-drive is rejected at the
2013
+ // dedupe gate until restart (#1149).
2014
+ this.opts.log?.error(
2015
+ `typed drain dispatch failed for ${event.messageId} (group of ${events.length}, claims freed for re-drive): ${String(err)}`,
1853
2016
  );
2017
+ for (const ev of events) clearTypedDedupeForEvent(this.laneFlowHost(), ev);
2018
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
2019
+ continue;
2020
+ }
2021
+ if (!dispatched) {
2022
+ // Shutdown: skip the ack so the server redelivers these buffered
2023
+ // events to the replacement pod via dispatch catch-up. Put both
2024
+ // the buffered events and the fork results back so nothing is
2025
+ // lost.
1854
2026
  this.dispatchState.mainBuffer.unshift(...events);
1855
2027
  this.dispatchState.pendingForkResults.unshift(...pendingFork);
1856
2028
  break;
1857
2029
  }
1858
- }
1859
- const dispatched = await this.runDispatch(
1860
- event,
1861
- this.opts.runtimeKey,
1862
- forkPrefix + buildEventBody(event),
1863
- earlier,
1864
- );
1865
- if (!dispatched) {
1866
- // Shutdown: skip the ack so the server redelivers these buffered
1867
- // events to the replacement pod via dispatch catch-up. Put both the
1868
- // buffered events and the fork results back so nothing is lost.
1869
- this.dispatchState.mainBuffer.unshift(...events);
1870
- this.dispatchState.pendingForkResults.unshift(...pendingFork);
1871
- break;
1872
- }
1873
- if (typedRefs) {
1874
- // Wrapper-less resolution of the buffered typed group — protocol
1875
- // lives in gateway-lane-flow.ts. The turn-error marker is consumed
1876
- // HERE, before this loop can start another turn on the session.
1877
- await settleDrainedTypedGroup(
1878
- this.laneFlowHost(),
1879
- events,
1880
- typedRefs,
1881
- this.consumeTurnError(this.opts.runtimeKey),
1882
- );
1883
- continue;
1884
- }
1885
- for (const bufferedEvent of events) {
1886
- const sourceType =
1887
- bufferedEvent.ackSourceType ??
1888
- (bufferedEvent.type === 'task' ? 'task_activity' : 'message');
1889
- const sourceId = bufferedEvent.ackSourceId ?? bufferedEvent.messageId;
1890
- this.opts.client
1891
- .ackDispatch(this.opts.config.org_id, {
1892
- source_type: sourceType,
1893
- source_id: sourceId,
1894
- })
1895
- .catch(() => {});
2030
+ if (typedRefs) {
2031
+ // Wrapper-less resolution of the buffered typed group — protocol
2032
+ // lives in gateway-lane-flow.ts. The turn-error marker is
2033
+ // consumed HERE, before this loop can start another turn on the
2034
+ // session.
2035
+ await settleDrainedTypedGroup(
2036
+ this.laneFlowHost(),
2037
+ events,
2038
+ typedRefs,
2039
+ this.consumeTurnError(this.opts.runtimeKey),
2040
+ );
2041
+ continue;
2042
+ }
2043
+ for (const bufferedEvent of events) {
2044
+ const sourceType =
2045
+ bufferedEvent.ackSourceType ??
2046
+ (bufferedEvent.type === 'task' ? 'task_activity' : 'message');
2047
+ const sourceId = bufferedEvent.ackSourceId ?? bufferedEvent.messageId;
2048
+ this.opts.client
2049
+ .ackDispatch(this.opts.config.org_id, {
2050
+ source_type: sourceType,
2051
+ source_id: sourceId,
2052
+ })
2053
+ .catch(() => {});
2054
+ // Retained-claim lifetime is the buffer stay on the legacy face
2055
+ // too: the fire-and-forget ack may fail (the row re-drives and
2056
+ // must not be self-rejected), and a shared-key task sibling must
2057
+ // not be blocked by this drained copy's claim. No-op for message
2058
+ // events (no typed dedupe entry).
2059
+ clearTypedDedupeForEvent(this.laneFlowHost(), bufferedEvent);
2060
+ }
2061
+ } finally {
2062
+ for (const id of drainingIds) this.drainingTypedIds.delete(id);
1896
2063
  }
1897
2064
  }
1898
2065
  } finally {
@@ -2021,10 +2188,27 @@ export class ParallAgentGateway {
2021
2188
  if (this.shuttingDown) {
2022
2189
  return false;
2023
2190
  }
2191
+ // A re-driven typed WorkItem may already be buffered from a prior
2192
+ // consume attempt (claim → busy main → buffer → release → server
2193
+ // re-drive): the buffered copy is the one the drain settles, so a
2194
+ // second copy would double the drain group's input steps and prompt
2195
+ // content. Drop the duplicate; the caller releases the row again and
2196
+ // the re-drive keeps converging on the buffered copy (#1149).
2197
+ if (this.isBufferedTypedWorkItem(event.dispatchEventId)) {
2198
+ return false;
2199
+ }
2024
2200
  // Push synchronously BEFORE the (possibly async) steer attempt so
2025
2201
  // arrival order is preserved and the event cannot be orphaned in a
2026
2202
  // gap between the steer await and the push.
2027
2203
  this.dispatchState.mainBuffer.push(event);
2204
+ // FIFO fence for BOTH injection branches below: a buffered typed
2205
+ // event is never injected, but the adapters track pending injections
2206
+ // as a COUNT, not by identity — if a message injected behind a
2207
+ // buffered typed event, the drain (typed group first, FIFO) would
2208
+ // consume the message's steer output as the typed group's turn: the
2209
+ // typed body never reaches the model yet resolves, and the message
2210
+ // replays. When anything un-injected sits ahead, buffer only.
2211
+ const typedAheadInBuffer = this.dispatchState.mainBuffer.some((e) => e.type !== 'message');
2028
2212
  if (this.usesLaneLedger(event)) {
2029
2213
  // Ledger flow: fold into the live lane server-side FIRST, then
2030
2214
  // inject. An un-folded injection is forbidden (the pending WorkItem
@@ -2039,6 +2223,7 @@ export class ParallAgentGateway {
2039
2223
  // the event un-folded keeps it buffered; the drain claims it as
2040
2224
  // its own turn and folds it there.
2041
2225
  if (
2226
+ !typedAheadInBuffer &&
2042
2227
  this.mainCurrentGroupKey === this.dispatchGroupKey(event) &&
2043
2228
  this.opts.dispatchAdapter.enqueueDuringDispatch != null &&
2044
2229
  (await this.laneLedger?.steerLive(event)) &&
@@ -2052,6 +2237,15 @@ export class ParallAgentGateway {
2052
2237
  );
2053
2238
  }
2054
2239
  } else if (
2240
+ // Message events only. A typed event (task_comment/schedule/…)
2241
+ // rides the typed-consume contract — buffer-main resolves false and
2242
+ // the claim releases for re-drive — so an injection here is exactly
2243
+ // the forbidden un-folded injection: the LLM sees the content while
2244
+ // the WorkItem stays live, and every re-drive injects it AGAIN (the
2245
+ // 7/16 watcher duplicate-delivery loop, #1149). Typed events stay
2246
+ // buffered; the drain claims them as their own turn.
2247
+ event.type === 'message' &&
2248
+ !typedAheadInBuffer &&
2055
2249
  this.dispatchState.mainCurrentTargetId === event.targetId &&
2056
2250
  (await this.opts.dispatchAdapter.enqueueDuringDispatch?.(
2057
2251
  this.opts.runtimeKey,
@@ -2362,7 +2556,7 @@ export class ParallAgentGateway {
2362
2556
  };
2363
2557
 
2364
2558
  const dispatched = await this.handleInboundEvent(event);
2365
- if (!dispatched) {
2559
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
2366
2560
  this.dispatchedTasks.delete(dedupeKey);
2367
2561
  }
2368
2562
  return dispatched;
@@ -2473,7 +2667,7 @@ export class ParallAgentGateway {
2473
2667
  this.dispatchedTasks.delete(dedupeKey);
2474
2668
  throw err;
2475
2669
  }
2476
- if (!dispatched) {
2670
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
2477
2671
  this.dispatchedTasks.delete(dedupeKey);
2478
2672
  }
2479
2673
  return dispatched;
@@ -2550,7 +2744,7 @@ export class ParallAgentGateway {
2550
2744
  this.dispatchedTasks.delete(dedupeKey);
2551
2745
  throw err;
2552
2746
  }
2553
- if (!dispatched) {
2747
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
2554
2748
  this.dispatchedTasks.delete(dedupeKey);
2555
2749
  }
2556
2750
  return dispatched;
@@ -2629,7 +2823,7 @@ export class ParallAgentGateway {
2629
2823
  this.dispatchedTasks.delete(dedupeKey);
2630
2824
  throw err;
2631
2825
  }
2632
- if (!dispatched) {
2826
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
2633
2827
  this.dispatchedTasks.delete(dedupeKey);
2634
2828
  }
2635
2829
  return dispatched;
@@ -2721,18 +2915,20 @@ export class ParallAgentGateway {
2721
2915
  }
2722
2916
  }
2723
2917
 
2724
- // The reply hint routes on the live capability grant: `<provider>-cli`
2725
- // present the vendor CLI is on PATH (broker shim) and is THE reply
2726
- // path; absent outbound is disabled for this org (flag/connection off)
2727
- // and the hint must say so instead of pointing at a retired clip. The
2728
- // provider label lookup above is best-effort/cosmetic when it fails,
2729
- // ANY granted `*-cli` capability keeps the hint on the CLI path: a
2730
- // transient metadata miss must not flip an actively granted agent's
2731
- // hint to "outbound disabled" and strand a valid external message.
2918
+ // The reply hint routes on the live capability grant, keyed PER
2919
+ // PROVIDER: feishu's affordance is the vendor CLI on PATH (`feishu-cli`
2920
+ // lark-cli, tier A) and slack's is the platform verb (`slack-send`
2921
+ // `parall slack send`, tier B there is no `slack-cli`). Absent
2922
+ // outbound is disabled for this org (flag/connection off) and the hint
2923
+ // must say so instead of pointing at a retired clip. The provider label
2924
+ // lookup above is best-effort/cosmetic when it fails, ANY granted
2925
+ // channel capability keeps the hint on the capability path: a transient
2926
+ // metadata miss must not flip an actively granted agent's hint to
2927
+ // "outbound disabled" and strand a valid external message.
2732
2928
  const keys = this.opts.getCapabilityKeys?.() ?? [];
2733
2929
  const cliCapable = provider
2734
- ? keys.includes(`${provider}-cli`)
2735
- : keys.some((k) => k.endsWith('-cli'));
2930
+ ? keys.includes(channelCapabilityKeyFor(provider))
2931
+ : keys.some((k) => k.endsWith('-cli') || k === CAPABILITY_SLACK_SEND);
2736
2932
 
2737
2933
  const event: ParallEvent = {
2738
2934
  type: 'channel_message',
@@ -2763,7 +2959,7 @@ export class ParallAgentGateway {
2763
2959
  this.dispatchedMessages.delete(claimKey);
2764
2960
  throw err;
2765
2961
  }
2766
- if (!dispatched) {
2962
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
2767
2963
  this.dispatchedMessages.delete(claimKey);
2768
2964
  }
2769
2965
  return dispatched;
@@ -2810,7 +3006,7 @@ export class ParallAgentGateway {
2810
3006
  this.dispatchedTasks.delete(dedupeKey);
2811
3007
  throw err;
2812
3008
  }
2813
- if (!dispatched) {
3009
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
2814
3010
  this.dispatchedTasks.delete(dedupeKey);
2815
3011
  }
2816
3012
  return dispatched;
@@ -2871,7 +3067,7 @@ export class ParallAgentGateway {
2871
3067
  this.dispatchedTasks.delete(dedupeKey);
2872
3068
  throw err;
2873
3069
  }
2874
- if (!dispatched) {
3070
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
2875
3071
  this.dispatchedTasks.delete(dedupeKey);
2876
3072
  }
2877
3073
  return dispatched;
@@ -2947,6 +3143,16 @@ export class ParallAgentGateway {
2947
3143
  continue;
2948
3144
  }
2949
3145
 
3146
+ // Same pre-claim guard as the dispatch.new handler: a WorkItem whose
3147
+ // event copy is already buffered belongs to the drain — claiming it
3148
+ // here would race the drain's fence-less settlement.
3149
+ if (item.event_type !== 'message' && this.isBufferedTypedWorkItem(item.id)) {
3150
+ this.opts.log?.info(
3151
+ `typed dispatch ${item.id} already buffered for the drain — skipping catch-up claim`,
3152
+ );
3153
+ continue;
3154
+ }
3155
+
2950
3156
  processed++;
2951
3157
  try {
2952
3158
  const typedHooks: TypedConsumeHooks = {
@@ -3254,6 +3460,32 @@ export class ParallAgentGateway {
3254
3460
 
3255
3461
  await this.opts.onBeforeDisconnect?.();
3256
3462
 
3463
+ // Parked step writes are process-local and their WorkItems are already
3464
+ // resolved — restart catch-up will NOT re-drive them, so anything still
3465
+ // parked at exit is permanently lost. Spend a slice of the shutdown
3466
+ // budget on one flush pass first: the common shutdown (idle-stop,
3467
+ // deploy) happens on a healthy network where these writes just succeed.
3468
+ // The 10s cap is hard — a write still in flight at the deadline is
3469
+ // abandoned to the background (see StepRetryQueue.flush).
3470
+ if (this.stepPersister.pendingTotal() > 0) {
3471
+ const remaining = await this.stepPersister.flush(10_000);
3472
+ if (remaining > 0) {
3473
+ this.opts.log?.warn(
3474
+ `${remaining} parked step write(s) could not be flushed at shutdown; they are permanently lost`,
3475
+ );
3476
+ }
3477
+ }
3478
+ this.stepPersister.dispose();
3479
+ // Lifecycle last: idle writes land after the flushed steps, so the
3480
+ // server clears activity once and no flushed step can relight it.
3481
+ const lifecycleRemaining = await this.sessionLifecycle.flush(5_000);
3482
+ if (lifecycleRemaining > 0) {
3483
+ this.opts.log?.warn(
3484
+ `${lifecycleRemaining} session lifecycle write(s) unreconciled at shutdown`,
3485
+ );
3486
+ }
3487
+ this.sessionLifecycle.dispose();
3488
+
3257
3489
  this.opts.ws.disconnect();
3258
3490
  this.opts.log?.info(`disconnected`);
3259
3491
  }