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