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