@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.
- package/dist/channel-capability.d.ts +2 -0
- package/dist/channel-capability.d.ts.map +1 -1
- package/dist/channel-capability.js +15 -0
- package/dist/dispatch-adapter.d.ts +9 -0
- package/dist/dispatch-adapter.d.ts.map +1 -1
- package/dist/event-format.d.ts.map +1 -1
- package/dist/event-format.js +27 -7
- package/dist/fork-session-finalizer.d.ts +65 -0
- package/dist/fork-session-finalizer.d.ts.map +1 -0
- package/dist/fork-session-finalizer.js +70 -0
- package/dist/gateway-base.d.ts +47 -0
- package/dist/gateway-base.d.ts.map +1 -1
- package/dist/gateway-base.js +457 -200
- package/dist/gateway-lane-flow.d.ts.map +1 -1
- package/dist/gateway-lane-flow.js +13 -7
- package/dist/http-keepalive.d.ts +4 -0
- package/dist/http-keepalive.d.ts.map +1 -0
- package/dist/http-keepalive.js +33 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/session-lifecycle.d.ts +198 -0
- package/dist/session-lifecycle.d.ts.map +1 -0
- package/dist/session-lifecycle.js +446 -0
- package/dist/skills/parall-clips.d.ts +1 -1
- package/dist/skills/parall-clips.d.ts.map +1 -1
- package/dist/skills/parall-clips.js +3 -0
- package/dist/step-persister.d.ts +66 -0
- package/dist/step-persister.d.ts.map +1 -0
- package/dist/step-persister.js +116 -0
- package/dist/step-retry-queue.d.ts +91 -0
- package/dist/step-retry-queue.d.ts.map +1 -0
- package/dist/step-retry-queue.js +259 -0
- package/package.json +3 -2
- package/src/channel-capability.ts +16 -0
- package/src/dispatch-adapter.ts +10 -0
- package/src/event-format.ts +27 -7
- package/src/fork-session-finalizer.ts +122 -0
- package/src/gateway-base.ts +487 -255
- package/src/gateway-lane-flow.ts +12 -7
- package/src/http-keepalive.ts +36 -0
- package/src/index.ts +6 -0
- package/src/session-lifecycle.ts +552 -0
- package/src/skills/parall-clips.ts +3 -0
- package/src/step-persister.ts +161 -0
- package/src/step-retry-queue.ts +296 -0
package/dist/gateway-base.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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 === '
|
|
654
|
-
? {
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
1103
|
-
this
|
|
1104
|
-
|
|
1105
|
-
|
|
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
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
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
|
-
|
|
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.
|
|
1612
|
+
dispatched = await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + body, earlier);
|
|
1432
1613
|
}
|
|
1433
1614
|
catch (err) {
|
|
1434
|
-
|
|
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
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
2187
|
-
//
|
|
2188
|
-
//
|
|
2189
|
-
//
|
|
2190
|
-
//
|
|
2191
|
-
//
|
|
2192
|
-
//
|
|
2193
|
-
//
|
|
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(
|
|
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
|
}
|