@parall/agent-core 1.45.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 (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,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, resolveDispatchByID, settleDrainedTypedGroup, typedLedgerEventIds, } 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
  }
@@ -285,6 +371,13 @@ export class ParallAgentGateway {
285
371
  }
286
372
  });
287
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
+ }
288
381
  if (data.event_type === 'task_assign') {
289
382
  if (!data.task_id)
290
383
  return;
@@ -518,7 +611,14 @@ export class ParallAgentGateway {
518
611
  // into one turn.
519
612
  return this.laneLedger.laneKeyFor(event);
520
613
  }
521
- 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}`;
522
622
  }
523
623
  // Lane-flow protocols live in gateway-lane-flow.ts; these thin delegates
524
624
  // keep call sites and tests on the class surface.
@@ -548,6 +648,25 @@ export class ParallAgentGateway {
548
648
  return false;
549
649
  });
550
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
+ }
551
670
  // PARITY: this switch and gateway-lane-flow's clearTypedDedupeForEvent must
552
671
  // handle the same typed source families — extend BOTH when adding a typed
553
672
  // event type (same dedupe entries, keyed from different event shapes).
@@ -614,106 +733,110 @@ export class ParallAgentGateway {
614
733
  }
615
734
  async createInputStep(sessionId, event) {
616
735
  const target = resolveStepTarget(event);
617
- try {
618
- await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
619
- step_type: 'input',
620
- target_type: target.target_type,
621
- target_id: target.target_id,
622
- content: {
623
- trigger_type: event.type === 'task'
624
- ? 'task_assign'
625
- : event.type === 'task_comment'
626
- ? 'task_comment'
627
- : event.type === 'wiki_comment'
628
- ? 'wiki_comment'
629
- : event.type === 'schedule'
630
- ? 'schedule_fire'
631
- : event.type === 'external_trigger'
632
- ? 'external_trigger'
633
- : event.type === 'channel_message'
634
- ? 'channel_message'
635
- : event.type === 'approval'
636
- ? 'approval_decided'
637
- : 'mention',
638
- trigger_ref: event.type === 'task'
639
- ? { task_id: event.targetId }
640
- : event.type === 'task_comment'
641
- ? { comment_id: event.messageId, task_id: event.targetId }
642
- : event.type === 'wiki_comment'
643
- ? { comment_id: event.messageId, target_uri: event.replyTargetUri }
644
- : event.type === 'schedule'
645
- ? { schedule_id: event.targetId, run_id: event.messageId }
646
- : 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'
647
773
  ? {
648
- trigger_id: event.targetId,
649
- run_id: event.messageId,
650
- connection_id: event.externalConnectionId,
651
- 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,
652
778
  }
653
- : event.type === 'channel_message'
654
- ? {
655
- conversation_id: event.targetId,
656
- channel_message_id: event.messageId,
657
- provider: event.channelProvider,
658
- external_conversation_id: event.channelExternalConversationId,
659
- }
660
- : event.type === 'approval'
661
- ? { approval_id: event.messageId }
662
- : { message_id: event.messageId },
663
- sender_id: event.senderId,
664
- sender_name: event.senderName,
665
- summary: event.body.substring(0, 200),
666
- ...(event.sentAt ? { sent_at: event.sentAt } : {}),
667
- },
668
- });
669
- }
670
- catch (err) {
671
- if (this.isSessionNotLiveError(err))
672
- throw err;
673
- this.opts.log?.warn(`failed to create input step: ${String(err)}`);
674
- }
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
+ });
675
788
  }
676
789
  async createRuntimeStep(sessionId, event, runtimeEvent, stepIdFilePath, contextFilePath, laneContextFilePath) {
677
790
  const target = resolveStepTarget(event);
678
- try {
679
- switch (runtimeEvent.type) {
680
- case 'thinking':
681
- await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
682
- step_type: 'thinking',
683
- target_type: target.target_type,
684
- target_id: target.target_id,
685
- content: { text: runtimeEvent.text },
686
- group_key: runtimeEvent.groupKey,
687
- });
688
- break;
689
- case 'text':
690
- await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
691
- step_type: 'text',
692
- target_type: target.target_type,
693
- target_id: target.target_id,
694
- content: {
695
- text: runtimeEvent.text,
696
- suppressed: runtimeEvent.project !== true,
697
- },
698
- projection: runtimeEvent.project === true,
699
- group_key: runtimeEvent.groupKey,
700
- });
701
- break;
702
- case 'tool_call': {
703
- const step = await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
704
- step_type: 'tool_call',
705
- target_type: target.target_type,
706
- target_id: target.target_id,
707
- content: {
708
- call_id: runtimeEvent.callId,
709
- tool_name: runtimeEvent.toolName,
710
- tool_input: runtimeEvent.input,
711
- status: 'running',
712
- started_at: runtimeEvent.startedAt ?? new Date().toISOString(),
713
- },
714
- group_key: runtimeEvent.groupKey,
715
- runtime_key: runtimeEvent.callId,
716
- });
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) {
717
840
  if (contextFilePath) {
718
841
  this.updateContextFileStepId(contextFilePath, step.id);
719
842
  }
@@ -723,48 +846,47 @@ export class ParallAgentGateway {
723
846
  if (laneContextFilePath) {
724
847
  this.updateContextFileStepId(laneContextFilePath, step.id);
725
848
  }
726
- break;
727
849
  }
728
- case 'tool_result':
729
- await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
730
- step_type: 'tool_result',
731
- target_type: target.target_type,
732
- target_id: target.target_id,
733
- content: {
734
- call_id: runtimeEvent.callId,
735
- tool_name: runtimeEvent.toolName,
736
- status: runtimeEvent.error ? 'error' : 'success',
737
- output: runtimeEvent.output,
738
- duration_ms: runtimeEvent.durationMs ?? 0,
739
- collapsible: true,
740
- },
741
- group_key: runtimeEvent.groupKey,
742
- });
743
- if (contextFilePath) {
744
- this.updateContextFileStepId(contextFilePath, null);
745
- }
746
- else if (stepIdFilePath) {
747
- this.clearStepIdFile(stepIdFilePath);
748
- }
749
- if (laneContextFilePath) {
750
- this.updateContextFileStepId(laneContextFilePath, null);
751
- }
752
- break;
753
- case 'error':
754
- await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
755
- step_type: 'text',
756
- target_type: target.target_type,
757
- target_id: target.target_id,
758
- content: buildErrorStepContent(runtimeEvent.message),
759
- projection: false,
760
- });
761
- break;
850
+ break;
762
851
  }
763
- }
764
- catch (err) {
765
- if (this.isSessionNotLiveError(err))
766
- throw err;
767
- 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;
768
890
  }
769
891
  }
770
892
  writeContextFile(filePath, ctx) {
@@ -948,9 +1070,20 @@ export class ParallAgentGateway {
948
1070
  : null;
949
1071
  let binding = this.sessionBindings.get(sessionKey);
950
1072
  let inputStepsCreated = false;
951
- let triggerMessageSet = false;
1073
+ let turnHandle;
952
1074
  let dispatchError;
953
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
+ };
954
1087
  try {
955
1088
  dispatchSpan = startDispatchSpan(event, this.opts.runtimeType, sessionKey);
956
1089
  for await (const runtimeEvent of this.opts.dispatchAdapter.dispatch({
@@ -980,6 +1113,7 @@ export class ParallAgentGateway {
980
1113
  // while a dispatch was in flight) inside the in-flight window so a
981
1114
  // shutdown short-circuit BEFORE this point cannot leave orphan input
982
1115
  // steps that the replacement pod would duplicate on replay.
1116
+ await ensureTurnBegun();
983
1117
  if (earlierEvents.length > 0) {
984
1118
  await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
985
1119
  }
@@ -992,12 +1126,7 @@ export class ParallAgentGateway {
992
1126
  const detail = runtimeEvent.type === 'error' ? `: ${runtimeEvent.message}` : '';
993
1127
  throw new Error(`runtime emitted ${runtimeEvent.type} before runtime_session${detail}`);
994
1128
  }
995
- if (!triggerMessageSet) {
996
- triggerMessageSet = true;
997
- this.opts.client
998
- .updateAgentSession(this.opts.config.org_id, this.opts.agentUserId, binding.agentSessionId, { status: 'active', trigger_message_id: event.messageId })
999
- .catch((err) => this.opts.log?.warn?.(`failed to set session active: ${err}`));
1000
- }
1129
+ await ensureTurnBegun();
1001
1130
  if (!inputStepsCreated) {
1002
1131
  if (earlierEvents.length > 0) {
1003
1132
  await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
@@ -1043,6 +1172,7 @@ export class ParallAgentGateway {
1043
1172
  throw new Error('runtime completed without runtime_session');
1044
1173
  }
1045
1174
  if (!inputStepsCreated) {
1175
+ await ensureTurnBegun();
1046
1176
  if (earlierEvents.length > 0) {
1047
1177
  await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
1048
1178
  }
@@ -1054,6 +1184,7 @@ export class ParallAgentGateway {
1054
1184
  let staleDetected = this.isSessionNotLiveError(err);
1055
1185
  if (!staleDetected && binding) {
1056
1186
  try {
1187
+ await ensureTurnBegun();
1057
1188
  await this.createRuntimeStep(binding.agentSessionId, event, {
1058
1189
  type: 'error',
1059
1190
  message: `Dispatch failed: ${String(err)}`,
@@ -1067,6 +1198,8 @@ export class ParallAgentGateway {
1067
1198
  if (staleDetected && binding) {
1068
1199
  this.opts.log?.warn?.(`session ${binding.agentSessionId} is stale (mid-dispatch), triggering recovery for ${sessionKey}`);
1069
1200
  this.sessionBindings.delete(sessionKey);
1201
+ this.stepPersister.dropSession(binding.agentSessionId);
1202
+ this.sessionLifecycle.dropSession(binding.agentSessionId);
1070
1203
  if (sessionKey === this.opts.runtimeKey) {
1071
1204
  this.activeSessionId = undefined;
1072
1205
  }
@@ -1099,10 +1232,10 @@ export class ParallAgentGateway {
1099
1232
  recordMissingReply(this.opts.runtimeType);
1100
1233
  }
1101
1234
  clearDispatchMetrics(sessionKey);
1102
- if (triggerMessageSet && binding) {
1103
- this.opts.client
1104
- .updateAgentSession(this.opts.config.org_id, this.opts.agentUserId, binding.agentSessionId, { status: 'idle' })
1105
- .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);
1106
1239
  }
1107
1240
  clearSessionMessageId(sessionKey);
1108
1241
  clearDispatchMessageId(sessionKey);
@@ -1344,10 +1477,18 @@ export class ParallAgentGateway {
1344
1477
  }
1345
1478
  }
1346
1479
  if (forkBinding) {
1347
- this.opts.client
1348
- .updateAgentSession(this.opts.config.org_id, this.opts.agentUserId, forkBinding.agentSessionId, { status: 'closed' })
1349
- .catch(() => { });
1350
- 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
+ });
1351
1492
  }
1352
1493
  }
1353
1494
  }
@@ -1426,43 +1567,101 @@ export class ParallAgentGateway {
1426
1567
  // false) and released the claims, so THIS site owns their resolution.
1427
1568
  // Message groups here are the ledger-disabled legacy flow.
1428
1569
  const typedRefs = this.typedLedgerEventIds(events);
1429
- if (!typedRefs) {
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);
1581
+ try {
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;
1430
1611
  try {
1431
- await this.emitDispatchReceived(event);
1612
+ dispatched = await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + body, earlier);
1432
1613
  }
1433
1614
  catch (err) {
1434
- this.opts.log?.warn?.(`mark-received failed for buffered dispatch, leaving unacked for retry: ${String(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.
1435
1632
  this.dispatchState.mainBuffer.unshift(...events);
1436
1633
  this.dispatchState.pendingForkResults.unshift(...pendingFork);
1437
1634
  break;
1438
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
+ }
1439
1661
  }
1440
- const dispatched = await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event), earlier);
1441
- if (!dispatched) {
1442
- // Shutdown: skip the ack so the server redelivers these buffered
1443
- // events to the replacement pod via dispatch catch-up. Put both the
1444
- // buffered events and the fork results back so nothing is lost.
1445
- this.dispatchState.mainBuffer.unshift(...events);
1446
- this.dispatchState.pendingForkResults.unshift(...pendingFork);
1447
- break;
1448
- }
1449
- if (typedRefs) {
1450
- // Wrapper-less resolution of the buffered typed group — protocol
1451
- // lives in gateway-lane-flow.ts. The turn-error marker is consumed
1452
- // HERE, before this loop can start another turn on the session.
1453
- await settleDrainedTypedGroup(this.laneFlowHost(), events, typedRefs, this.consumeTurnError(this.opts.runtimeKey));
1454
- continue;
1455
- }
1456
- for (const bufferedEvent of events) {
1457
- const sourceType = bufferedEvent.ackSourceType ??
1458
- (bufferedEvent.type === 'task' ? 'task_activity' : 'message');
1459
- const sourceId = bufferedEvent.ackSourceId ?? bufferedEvent.messageId;
1460
- this.opts.client
1461
- .ackDispatch(this.opts.config.org_id, {
1462
- source_type: sourceType,
1463
- source_id: sourceId,
1464
- })
1465
- .catch(() => { });
1662
+ finally {
1663
+ for (const id of drainingIds)
1664
+ this.drainingTypedIds.delete(id);
1466
1665
  }
1467
1666
  }
1468
1667
  }
@@ -1578,10 +1777,27 @@ export class ParallAgentGateway {
1578
1777
  if (this.shuttingDown) {
1579
1778
  return false;
1580
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
+ }
1581
1789
  // Push synchronously BEFORE the (possibly async) steer attempt so
1582
1790
  // arrival order is preserved and the event cannot be orphaned in a
1583
1791
  // gap between the steer await and the push.
1584
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');
1585
1801
  if (this.usesLaneLedger(event)) {
1586
1802
  // Ledger flow: fold into the live lane server-side FIRST, then
1587
1803
  // inject. An un-folded injection is forbidden (the pending WorkItem
@@ -1595,14 +1811,25 @@ export class ParallAgentGateway {
1595
1811
  // current turn's reply without the model ever seeing it. Leaving
1596
1812
  // the event un-folded keeps it buffered; the drain claims it as
1597
1813
  // its own turn and folds it there.
1598
- if (this.mainCurrentGroupKey === this.dispatchGroupKey(event) &&
1814
+ if (!typedAheadInBuffer &&
1815
+ this.mainCurrentGroupKey === this.dispatchGroupKey(event) &&
1599
1816
  this.opts.dispatchAdapter.enqueueDuringDispatch != null &&
1600
1817
  (await this.laneLedger?.steerLive(event)) &&
1601
1818
  (await this.opts.dispatchAdapter.enqueueDuringDispatch(this.opts.runtimeKey, buildEventBody(event)))) {
1602
1819
  this.opts.log?.info(`steer folded+injected for ${event.messageId} (will drain for bookkeeping)`);
1603
1820
  }
1604
1821
  }
1605
- 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 &&
1606
1833
  (await this.opts.dispatchAdapter.enqueueDuringDispatch?.(this.opts.runtimeKey, buildEventBody(event)))) {
1607
1834
  this.opts.log?.info(`steer injected for ${event.messageId} (will drain for bookkeeping)`);
1608
1835
  }
@@ -1874,7 +2101,7 @@ export class ParallAgentGateway {
1874
2101
  dispatchEventId,
1875
2102
  };
1876
2103
  const dispatched = await this.handleInboundEvent(event);
1877
- if (!dispatched) {
2104
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
1878
2105
  this.dispatchedTasks.delete(dedupeKey);
1879
2106
  }
1880
2107
  return dispatched;
@@ -1971,7 +2198,7 @@ export class ParallAgentGateway {
1971
2198
  this.dispatchedTasks.delete(dedupeKey);
1972
2199
  throw err;
1973
2200
  }
1974
- if (!dispatched) {
2201
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
1975
2202
  this.dispatchedTasks.delete(dedupeKey);
1976
2203
  }
1977
2204
  return dispatched;
@@ -2040,7 +2267,7 @@ export class ParallAgentGateway {
2040
2267
  this.dispatchedTasks.delete(dedupeKey);
2041
2268
  throw err;
2042
2269
  }
2043
- if (!dispatched) {
2270
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
2044
2271
  this.dispatchedTasks.delete(dedupeKey);
2045
2272
  }
2046
2273
  return dispatched;
@@ -2110,7 +2337,7 @@ export class ParallAgentGateway {
2110
2337
  this.dispatchedTasks.delete(dedupeKey);
2111
2338
  throw err;
2112
2339
  }
2113
- if (!dispatched) {
2340
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
2114
2341
  this.dispatchedTasks.delete(dedupeKey);
2115
2342
  }
2116
2343
  return dispatched;
@@ -2183,18 +2410,20 @@ export class ParallAgentGateway {
2183
2410
  provider = undefined; // label degrades; reply hint still names the clip generically
2184
2411
  }
2185
2412
  }
2186
- // The reply hint routes on the live capability grant: `<provider>-cli`
2187
- // present the vendor CLI is on PATH (broker shim) and is THE reply
2188
- // path; absent outbound is disabled for this org (flag/connection off)
2189
- // and the hint must say so instead of pointing at a retired clip. The
2190
- // provider label lookup above is best-effort/cosmetic when it fails,
2191
- // ANY granted `*-cli` capability keeps the hint on the CLI path: a
2192
- // transient metadata miss must not flip an actively granted agent's
2193
- // 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.
2194
2423
  const keys = this.opts.getCapabilityKeys?.() ?? [];
2195
2424
  const cliCapable = provider
2196
- ? keys.includes(`${provider}-cli`)
2197
- : keys.some((k) => k.endsWith('-cli'));
2425
+ ? keys.includes(channelCapabilityKeyFor(provider))
2426
+ : keys.some((k) => k.endsWith('-cli') || k === CAPABILITY_SLACK_SEND);
2198
2427
  const event = {
2199
2428
  type: 'channel_message',
2200
2429
  targetId: conv.id,
@@ -2224,7 +2453,7 @@ export class ParallAgentGateway {
2224
2453
  this.dispatchedMessages.delete(claimKey);
2225
2454
  throw err;
2226
2455
  }
2227
- if (!dispatched) {
2456
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
2228
2457
  this.dispatchedMessages.delete(claimKey);
2229
2458
  }
2230
2459
  return dispatched;
@@ -2267,7 +2496,7 @@ export class ParallAgentGateway {
2267
2496
  this.dispatchedTasks.delete(dedupeKey);
2268
2497
  throw err;
2269
2498
  }
2270
- if (!dispatched) {
2499
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
2271
2500
  this.dispatchedTasks.delete(dedupeKey);
2272
2501
  }
2273
2502
  return dispatched;
@@ -2319,7 +2548,7 @@ export class ParallAgentGateway {
2319
2548
  this.dispatchedTasks.delete(dedupeKey);
2320
2549
  throw err;
2321
2550
  }
2322
- if (!dispatched) {
2551
+ if (!dispatched && !this.isTypedEventBuffered(event)) {
2323
2552
  this.dispatchedTasks.delete(dedupeKey);
2324
2553
  }
2325
2554
  return dispatched;
@@ -2389,6 +2618,13 @@ export class ParallAgentGateway {
2389
2618
  }
2390
2619
  continue;
2391
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
+ }
2392
2628
  processed++;
2393
2629
  try {
2394
2630
  const typedHooks = {
@@ -2611,6 +2847,27 @@ export class ParallAgentGateway {
2611
2847
  await this.laneLedger.releaseAll();
2612
2848
  }
2613
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();
2614
2871
  this.opts.ws.disconnect();
2615
2872
  this.opts.log?.info(`disconnected`);
2616
2873
  }