@parall/agent-core 1.36.1 → 1.38.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/bridge-workspace.d.ts +1 -1
- package/dist/bridge-workspace.d.ts.map +1 -1
- package/dist/bridge-workspace.js +13 -3
- package/dist/dispatch-adapter.d.ts +6 -0
- package/dist/dispatch-adapter.d.ts.map +1 -1
- package/dist/event-format.d.ts.map +1 -1
- package/dist/event-format.js +36 -1
- package/dist/gateway-base.d.ts +56 -0
- package/dist/gateway-base.d.ts.map +1 -1
- package/dist/gateway-base.js +459 -97
- package/dist/gateway-lane-flow.d.ts +74 -0
- package/dist/gateway-lane-flow.d.ts.map +1 -0
- package/dist/gateway-lane-flow.js +167 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/lane-key.d.ts +45 -0
- package/dist/lane-key.d.ts.map +1 -0
- package/dist/lane-key.js +34 -0
- package/dist/lane-ledger.d.ts +112 -0
- package/dist/lane-ledger.d.ts.map +1 -0
- package/dist/lane-ledger.js +333 -0
- package/dist/platform-config.d.ts +19 -0
- package/dist/platform-config.d.ts.map +1 -1
- package/dist/platform-config.js +72 -9
- package/dist/prompt-fragments.d.ts +1 -1
- package/dist/prompt-fragments.d.ts.map +1 -1
- package/dist/prompt-fragments.js +2 -0
- package/dist/skills/parall-platform.d.ts +1 -1
- package/dist/skills/parall-platform.d.ts.map +1 -1
- package/dist/skills/parall-platform.js +27 -6
- package/dist/types.d.ts +11 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/bridge-workspace.ts +13 -3
- package/src/dispatch-adapter.ts +6 -0
- package/src/event-format.ts +38 -1
- package/src/gateway-base.ts +637 -143
- package/src/gateway-lane-flow.ts +235 -0
- package/src/index.ts +2 -0
- package/src/lane-key.ts +67 -0
- package/src/lane-ledger.ts +370 -0
- package/src/platform-config.ts +85 -9
- package/src/prompt-fragments.ts +2 -0
- package/src/skills/parall-platform.ts +27 -6
- package/src/types.ts +17 -1
package/dist/gateway-base.js
CHANGED
|
@@ -3,6 +3,8 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import { ApiError, MENTION_ALL_USER_ID } from '@parall/sdk';
|
|
5
5
|
import { buildEventBody, buildEventBodyForForkResult, buildForkResultPrefix, buildForkScopePrefix, } from './event-format.js';
|
|
6
|
+
import { consumeMessageWorkItem, consumeTypedDispatch, dispatchLaneGroup, } from './gateway-lane-flow.js';
|
|
7
|
+
import { LaneLedger } from './lane-ledger.js';
|
|
6
8
|
import { routeTrigger } from './routing.js';
|
|
7
9
|
import { clearDispatchMessageId, clearDispatchMetrics, clearDispatchNoReply, clearSessionMessageId, getDispatchMetrics, recordDeliverText, recordMessageSend, recordNoReply, recordToolCall, resetDispatchMetrics, setDispatchMessageId, setDispatchNoReply, setSessionChatId, setSessionMessageId, } from './session-state.js';
|
|
8
10
|
import { isParallSendCommand, isParallNoReplyCommand, extractShellCommand, } from './bridge-workspace.js';
|
|
@@ -53,6 +55,9 @@ function resolveStepTarget(event) {
|
|
|
53
55
|
if (event.type === 'external_trigger' || event.targetId.startsWith('xtr_')) {
|
|
54
56
|
return { target_type: 'external_trigger', target_id: event.targetId };
|
|
55
57
|
}
|
|
58
|
+
if (event.type === 'channel_message' || event.targetId.startsWith('chv_')) {
|
|
59
|
+
return { target_type: 'channel_conversation', target_id: event.targetId };
|
|
60
|
+
}
|
|
56
61
|
if (event.type === 'wiki_comment') {
|
|
57
62
|
// target_id is the full wiki target_uri (scheme-stripped routing key). The
|
|
58
63
|
// server stores target_type freely and only publishes step WS events /
|
|
@@ -115,6 +120,9 @@ export class ParallAgentGateway {
|
|
|
115
120
|
opts;
|
|
116
121
|
chatInfoMap = new Map();
|
|
117
122
|
dispatchedTasks = new Set();
|
|
123
|
+
// connection id → provider alias, for channel_message prompt labeling
|
|
124
|
+
// (stable mapping; avoids one connection fetch per inbound message).
|
|
125
|
+
channelConnectionProviders = new Map();
|
|
118
126
|
dispatchedMessages = new Set();
|
|
119
127
|
forkStates = new Map();
|
|
120
128
|
dispatchState = {
|
|
@@ -137,6 +145,14 @@ export class ParallAgentGateway {
|
|
|
137
145
|
inFlightDispatches = 0;
|
|
138
146
|
drainResolvers = [];
|
|
139
147
|
pendingRestartNotification = null;
|
|
148
|
+
laneLedger;
|
|
149
|
+
// Sticky fallback: flipped when the server predates the ledger (claim
|
|
150
|
+
// endpoint 404) so every subsequent dispatch uses the legacy flow.
|
|
151
|
+
ledgerDisabled = false;
|
|
152
|
+
// Group key of the group currently being dispatched on main — lane-aware
|
|
153
|
+
// (targetId + thread), unlike mainCurrentTargetId which stays chat-level
|
|
154
|
+
// for fork routing decisions.
|
|
155
|
+
mainCurrentGroupKey;
|
|
140
156
|
DISPATCHED_MESSAGES_CAP = 5000;
|
|
141
157
|
// SHUTDOWN_DEADLINE_MS is read by waitForDrain via the configured value
|
|
142
158
|
// below — kept as instance state so per-runtime configs can override it
|
|
@@ -146,6 +162,14 @@ export class ParallAgentGateway {
|
|
|
146
162
|
DISPATCH_DEADLINE_MS;
|
|
147
163
|
constructor(opts) {
|
|
148
164
|
this.opts = opts;
|
|
165
|
+
if (opts.dispatchContextDir) {
|
|
166
|
+
this.laneLedger = new LaneLedger({
|
|
167
|
+
client: opts.client,
|
|
168
|
+
orgId: opts.config.org_id,
|
|
169
|
+
contextDir: opts.dispatchContextDir,
|
|
170
|
+
log: opts.log,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
149
173
|
this.SHUTDOWN_DEADLINE_MS = opts.shutdownDeadlineMs ?? 60_000;
|
|
150
174
|
this.FORK_DEADLINE_MS = opts.forkDeadlineMs ?? 2 * 60 * 60_000;
|
|
151
175
|
this.DISPATCH_DEADLINE_MS = opts.dispatchDeadlineMs ?? 20 * 60_000;
|
|
@@ -222,15 +246,26 @@ export class ParallAgentGateway {
|
|
|
222
246
|
if (data.status !== 'todo' && data.status !== 'in_progress')
|
|
223
247
|
return;
|
|
224
248
|
try {
|
|
225
|
-
|
|
226
|
-
|
|
249
|
+
// Prefer the exact WorkItem id the server threads through the event —
|
|
250
|
+
// a task PATCH can enqueue sibling task_assign + task_update rows
|
|
251
|
+
// under the same (task_activity, task_id) source tuple, and source-
|
|
252
|
+
// level claim/ack would consume or clear the wrong sibling.
|
|
253
|
+
await this.consumeTypedDispatch(data.dispatch_event_id
|
|
254
|
+
? { dispatchEventId: data.dispatch_event_id }
|
|
255
|
+
: { sourceType: 'task_activity', sourceId: data.id }, (dispatchEventId) => this.handleTaskAssignment(data, data.id, dispatchEventId), (dispatchEventId) => {
|
|
256
|
+
if (dispatchEventId) {
|
|
257
|
+
this.opts.client
|
|
258
|
+
.ackDispatchByID(this.opts.config.org_id, dispatchEventId)
|
|
259
|
+
.catch(() => { });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
227
262
|
this.opts.client
|
|
228
263
|
.ackDispatch(this.opts.config.org_id, {
|
|
229
264
|
source_type: 'task_activity',
|
|
230
265
|
source_id: data.id,
|
|
231
266
|
})
|
|
232
267
|
.catch(() => { });
|
|
233
|
-
}
|
|
268
|
+
});
|
|
234
269
|
}
|
|
235
270
|
catch (err) {
|
|
236
271
|
this.opts.log?.error(`task dispatch failed for ${data.id}: ${String(err)}`);
|
|
@@ -241,10 +276,9 @@ export class ParallAgentGateway {
|
|
|
241
276
|
if (!data.source_id || !data.task_id)
|
|
242
277
|
return;
|
|
243
278
|
try {
|
|
244
|
-
|
|
245
|
-
if (dispatched) {
|
|
279
|
+
await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.handleTaskComment(data.source_id, data.task_id ?? '', data.actor_id, data.delivery_reason), () => {
|
|
246
280
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
|
|
247
|
-
}
|
|
281
|
+
});
|
|
248
282
|
}
|
|
249
283
|
catch (err) {
|
|
250
284
|
this.opts.log?.error(`task comment dispatch failed for ${data.source_id}: ${String(err)}`);
|
|
@@ -254,10 +288,9 @@ export class ParallAgentGateway {
|
|
|
254
288
|
if (!data.source_id)
|
|
255
289
|
return;
|
|
256
290
|
try {
|
|
257
|
-
|
|
258
|
-
if (dispatched) {
|
|
291
|
+
await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.handleWikiComment(data.source_id, data.actor_id, data.delivery_reason), () => {
|
|
259
292
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
|
|
260
|
-
}
|
|
293
|
+
});
|
|
261
294
|
}
|
|
262
295
|
catch (err) {
|
|
263
296
|
this.opts.log?.error(`wiki comment dispatch failed for ${data.source_id}: ${String(err)}`);
|
|
@@ -267,10 +300,12 @@ export class ParallAgentGateway {
|
|
|
267
300
|
if (!data.task_id)
|
|
268
301
|
return;
|
|
269
302
|
try {
|
|
270
|
-
|
|
271
|
-
|
|
303
|
+
await this.consumeTypedDispatch({ dispatchEventId: data.id }, (dispatchEventId) => this.handleTaskDispatch(data.task_id ?? '', data.source_id ?? data.task_id ?? '', {
|
|
304
|
+
allowCreator: true,
|
|
305
|
+
dispatchEventId,
|
|
306
|
+
}), () => {
|
|
272
307
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
|
|
273
|
-
}
|
|
308
|
+
});
|
|
274
309
|
}
|
|
275
310
|
catch (err) {
|
|
276
311
|
this.opts.log?.error(`task update dispatch failed for ${data.task_id}: ${String(err)}`);
|
|
@@ -280,10 +315,9 @@ export class ParallAgentGateway {
|
|
|
280
315
|
if (!data.source_id)
|
|
281
316
|
return;
|
|
282
317
|
try {
|
|
283
|
-
|
|
284
|
-
if (dispatched) {
|
|
318
|
+
await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleScheduleFire(data.source_id, data.actor_id), () => {
|
|
285
319
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
|
|
286
|
-
}
|
|
320
|
+
});
|
|
287
321
|
}
|
|
288
322
|
catch (err) {
|
|
289
323
|
this.opts.log?.error(`schedule fire dispatch failed for ${data.source_id}: ${String(err)}`);
|
|
@@ -293,28 +327,52 @@ export class ParallAgentGateway {
|
|
|
293
327
|
if (!data.source_id)
|
|
294
328
|
return;
|
|
295
329
|
try {
|
|
296
|
-
|
|
297
|
-
if (dispatched) {
|
|
330
|
+
await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleExternalTriggerRun(data.source_id), () => {
|
|
298
331
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
|
|
299
|
-
}
|
|
332
|
+
});
|
|
300
333
|
}
|
|
301
334
|
catch (err) {
|
|
302
335
|
this.opts.log?.error(`external trigger dispatch failed for ${data.source_id}: ${String(err)}`);
|
|
303
336
|
}
|
|
304
337
|
}
|
|
338
|
+
else if (data.event_type === 'channel_message') {
|
|
339
|
+
if (!data.source_id)
|
|
340
|
+
return;
|
|
341
|
+
try {
|
|
342
|
+
await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleChannelMessage(data.source_id), () => {
|
|
343
|
+
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
this.opts.log?.error(`channel message dispatch failed for ${data.source_id}: ${String(err)}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
305
350
|
else if (data.event_type === 'approval_decided') {
|
|
306
351
|
if (!data.source_id)
|
|
307
352
|
return;
|
|
308
353
|
try {
|
|
309
|
-
|
|
310
|
-
if (dispatched) {
|
|
354
|
+
await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleApprovalDecided(data.source_id, data.actor_id, data.chat_id ?? null), () => {
|
|
311
355
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
|
|
312
|
-
}
|
|
356
|
+
});
|
|
313
357
|
}
|
|
314
358
|
catch (err) {
|
|
315
359
|
this.opts.log?.error(`approval decided dispatch failed for ${data.source_id}: ${String(err)}`);
|
|
316
360
|
}
|
|
317
361
|
}
|
|
362
|
+
else if (data.event_type === 'message' &&
|
|
363
|
+
this.laneLedger &&
|
|
364
|
+
data.source_id &&
|
|
365
|
+
data.chat_id) {
|
|
366
|
+
// Ledger re-drive hint: a pending message WorkItem re-published after
|
|
367
|
+
// a same-target lane completed (claim previously refused, or a steer
|
|
368
|
+
// failed). Live first delivery stays on the message.new handler.
|
|
369
|
+
try {
|
|
370
|
+
await this.handleMessageRedrive(data);
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
this.opts.log?.error(`message re-drive failed for ${data.source_id}: ${String(err)}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
318
376
|
else if (data.event_type !== 'message' && data.event_type !== 'task_assign') {
|
|
319
377
|
// Truly unknown event_type — log so a newly-added dispatch type
|
|
320
378
|
// not yet wired here surfaces during runtime testing. "message"
|
|
@@ -357,6 +415,43 @@ export class ParallAgentGateway {
|
|
|
357
415
|
source_id: sourceId,
|
|
358
416
|
});
|
|
359
417
|
}
|
|
418
|
+
/** True when this event's lifecycle is owned by the dispatch lane ledger. */
|
|
419
|
+
usesLaneLedger(event) {
|
|
420
|
+
return this.laneLedger != null && !this.ledgerDisabled && this.laneLedger.handles(event);
|
|
421
|
+
}
|
|
422
|
+
disableLedger(reason) {
|
|
423
|
+
if (this.ledgerDisabled)
|
|
424
|
+
return;
|
|
425
|
+
this.ledgerDisabled = true;
|
|
426
|
+
this.opts.log?.warn(`dispatch ledger unavailable (${reason}) — falling back to legacy received/ack flow`);
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Buffer grouping key. Lane-ledger message events group by full lane
|
|
430
|
+
* identity (chat + thread) so a channel lane and a thread lane in the same
|
|
431
|
+
* chat dispatch as separate turns with separate claims; everything else
|
|
432
|
+
* keeps the historical chat-level grouping.
|
|
433
|
+
*/
|
|
434
|
+
dispatchGroupKey(event) {
|
|
435
|
+
if (this.usesLaneLedger(event)) {
|
|
436
|
+
// MUST be the lane identity itself (lane-key SSOT): the mid-turn
|
|
437
|
+
// injection gate compares this against mainCurrentGroupKey, and a
|
|
438
|
+
// grouping key that drifted from lane identity would fold two lanes
|
|
439
|
+
// into one turn.
|
|
440
|
+
return this.laneLedger.laneKeyFor(event);
|
|
441
|
+
}
|
|
442
|
+
return event.targetId;
|
|
443
|
+
}
|
|
444
|
+
// Lane-flow protocols live in gateway-lane-flow.ts; these thin delegates
|
|
445
|
+
// keep call sites and tests on the class surface.
|
|
446
|
+
laneFlowHost() {
|
|
447
|
+
return this;
|
|
448
|
+
}
|
|
449
|
+
dispatchLaneGroup(opts) {
|
|
450
|
+
return dispatchLaneGroup(this.laneFlowHost(), opts);
|
|
451
|
+
}
|
|
452
|
+
consumeTypedDispatch(ref, run, ack) {
|
|
453
|
+
return consumeTypedDispatch(this.laneFlowHost(), ref, run, ack);
|
|
454
|
+
}
|
|
360
455
|
buildDispatchContext(event, sessionKey) {
|
|
361
456
|
const binding = this.sessionBindings.get(sessionKey);
|
|
362
457
|
return {
|
|
@@ -373,6 +468,7 @@ export class ParallAgentGateway {
|
|
|
373
468
|
noReply: event.noReply ?? false,
|
|
374
469
|
contextFilePath: this.opts.contextFilePathForSession?.(sessionKey),
|
|
375
470
|
stepIdFilePath: this.opts.stepIdFilePathForSession?.(sessionKey),
|
|
471
|
+
contextDirPath: this.opts.dispatchContextDir,
|
|
376
472
|
client: this.opts.client,
|
|
377
473
|
log: this.opts.log,
|
|
378
474
|
};
|
|
@@ -400,9 +496,11 @@ export class ParallAgentGateway {
|
|
|
400
496
|
? 'schedule_fire'
|
|
401
497
|
: event.type === 'external_trigger'
|
|
402
498
|
? 'external_trigger'
|
|
403
|
-
: event.type === '
|
|
404
|
-
? '
|
|
405
|
-
: '
|
|
499
|
+
: event.type === 'channel_message'
|
|
500
|
+
? 'channel_message'
|
|
501
|
+
: event.type === 'approval'
|
|
502
|
+
? 'approval_decided'
|
|
503
|
+
: 'mention',
|
|
406
504
|
trigger_ref: event.type === 'task'
|
|
407
505
|
? { task_id: event.targetId }
|
|
408
506
|
: event.type === 'task_comment'
|
|
@@ -418,9 +516,16 @@ export class ParallAgentGateway {
|
|
|
418
516
|
connection_id: event.externalConnectionId,
|
|
419
517
|
ingress_event_id: event.externalIngressEventId,
|
|
420
518
|
}
|
|
421
|
-
: event.type === '
|
|
422
|
-
? {
|
|
423
|
-
|
|
519
|
+
: event.type === 'channel_message'
|
|
520
|
+
? {
|
|
521
|
+
conversation_id: event.targetId,
|
|
522
|
+
channel_message_id: event.messageId,
|
|
523
|
+
provider: event.channelProvider,
|
|
524
|
+
external_conversation_id: event.channelExternalConversationId,
|
|
525
|
+
}
|
|
526
|
+
: event.type === 'approval'
|
|
527
|
+
? { approval_id: event.messageId }
|
|
528
|
+
: { message_id: event.messageId },
|
|
424
529
|
sender_id: event.senderId,
|
|
425
530
|
sender_name: event.senderName,
|
|
426
531
|
summary: event.body.substring(0, 200),
|
|
@@ -434,7 +539,7 @@ export class ParallAgentGateway {
|
|
|
434
539
|
this.opts.log?.warn(`failed to create input step: ${String(err)}`);
|
|
435
540
|
}
|
|
436
541
|
}
|
|
437
|
-
async createRuntimeStep(sessionId, event, runtimeEvent, stepIdFilePath, contextFilePath) {
|
|
542
|
+
async createRuntimeStep(sessionId, event, runtimeEvent, stepIdFilePath, contextFilePath, laneContextFilePath) {
|
|
438
543
|
const target = resolveStepTarget(event);
|
|
439
544
|
try {
|
|
440
545
|
switch (runtimeEvent.type) {
|
|
@@ -481,6 +586,9 @@ export class ParallAgentGateway {
|
|
|
481
586
|
else if (stepIdFilePath) {
|
|
482
587
|
this.writeStepIdFile(stepIdFilePath, step.id);
|
|
483
588
|
}
|
|
589
|
+
if (laneContextFilePath) {
|
|
590
|
+
this.updateContextFileStepId(laneContextFilePath, step.id);
|
|
591
|
+
}
|
|
484
592
|
break;
|
|
485
593
|
}
|
|
486
594
|
case 'tool_result':
|
|
@@ -504,6 +612,9 @@ export class ParallAgentGateway {
|
|
|
504
612
|
else if (stepIdFilePath) {
|
|
505
613
|
this.clearStepIdFile(stepIdFilePath);
|
|
506
614
|
}
|
|
615
|
+
if (laneContextFilePath) {
|
|
616
|
+
this.updateContextFileStepId(laneContextFilePath, null);
|
|
617
|
+
}
|
|
507
618
|
break;
|
|
508
619
|
case 'error':
|
|
509
620
|
await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
|
|
@@ -577,7 +688,7 @@ export class ParallAgentGateway {
|
|
|
577
688
|
await this.createInputStep(sessionId, event);
|
|
578
689
|
}
|
|
579
690
|
}
|
|
580
|
-
async bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath) {
|
|
691
|
+
async bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath, laneContextFilePath) {
|
|
581
692
|
const runtimeLaneKey = runtimeEvent.runtimeLaneKey || sessionKey;
|
|
582
693
|
const existing = this.sessionBindings.get(sessionKey);
|
|
583
694
|
if (existing &&
|
|
@@ -626,6 +737,9 @@ export class ParallAgentGateway {
|
|
|
626
737
|
if (contextFilePath) {
|
|
627
738
|
this.updateContextFileSessionId(contextFilePath, session.id);
|
|
628
739
|
}
|
|
740
|
+
if (laneContextFilePath) {
|
|
741
|
+
this.updateContextFileSessionId(laneContextFilePath, session.id);
|
|
742
|
+
}
|
|
629
743
|
await this.opts.onSessionBinding?.(binding);
|
|
630
744
|
return binding;
|
|
631
745
|
}
|
|
@@ -655,14 +769,32 @@ export class ParallAgentGateway {
|
|
|
655
769
|
const dispatchContext = this.buildDispatchContext(event, sessionKey);
|
|
656
770
|
const contextFilePath = dispatchContext.contextFilePath;
|
|
657
771
|
const stepIdFilePath = dispatchContext.stepIdFilePath;
|
|
772
|
+
// Per-lane context (PRLL_CONTEXT_DIR contract): additive dispatch/lane
|
|
773
|
+
// fields ride along in both files; the per-session file stays as the
|
|
774
|
+
// PRLL_CONTEXT_FILE compat read path.
|
|
775
|
+
const activeLane = this.ledgerDisabled ? undefined : this.laneLedger?.getForEvent(event);
|
|
776
|
+
const laneContextFilePath = activeLane
|
|
777
|
+
? this.laneLedger?.laneContextPath(activeLane)
|
|
778
|
+
: undefined;
|
|
779
|
+
const contextBody = {
|
|
780
|
+
session_id: dispatchContext.sessionId ?? null,
|
|
781
|
+
chat_id: dispatchContext.chatId ?? null,
|
|
782
|
+
trigger_message_id: dispatchContext.triggerMessageId ?? null,
|
|
783
|
+
no_reply: dispatchContext.noReply,
|
|
784
|
+
step_id: null,
|
|
785
|
+
dispatch_event_id: activeLane?.typedDispatchEventId ?? activeLane?.folded.get(event.messageId) ?? null,
|
|
786
|
+
lane: activeLane?.lane ?? null,
|
|
787
|
+
target_uri: activeLane?.targetUri ?? null,
|
|
788
|
+
thread_root_id: activeLane?.threadRootId ?? null,
|
|
789
|
+
// Typed binding hint for the CLI: which task this dispatch is about
|
|
790
|
+
// (parall task update attaches the typed effect only on a match).
|
|
791
|
+
task_id: event.type === 'task' ? event.targetId : null,
|
|
792
|
+
};
|
|
658
793
|
if (contextFilePath) {
|
|
659
|
-
this.writeContextFile(contextFilePath,
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
no_reply: dispatchContext.noReply,
|
|
664
|
-
step_id: null,
|
|
665
|
-
});
|
|
794
|
+
this.writeContextFile(contextFilePath, contextBody);
|
|
795
|
+
}
|
|
796
|
+
if (laneContextFilePath) {
|
|
797
|
+
this.writeContextFile(laneContextFilePath, contextBody);
|
|
666
798
|
}
|
|
667
799
|
// sync: no await between the shuttingDown check above and this increment
|
|
668
800
|
// — JS event loop is single-threaded, so shutdown() cannot interleave
|
|
@@ -694,7 +826,20 @@ export class ParallAgentGateway {
|
|
|
694
826
|
context: dispatchContext,
|
|
695
827
|
})) {
|
|
696
828
|
if (runtimeEvent.type === 'runtime_session') {
|
|
697
|
-
|
|
829
|
+
const priorAgentSessionId = binding?.agentSessionId;
|
|
830
|
+
binding = await this.bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath, laneContextFilePath);
|
|
831
|
+
if (event.targetType === 'channel_conversation' &&
|
|
832
|
+
binding.agentSessionId !== priorAgentSessionId) {
|
|
833
|
+
// Record the durable chv_ ↔ ase_ mapping (ops drill-down from a
|
|
834
|
+
// conversation into its session). Best-effort bookkeeping —
|
|
835
|
+
// never fail the dispatch over it.
|
|
836
|
+
try {
|
|
837
|
+
await this.opts.client.setChannelConversationSession(this.opts.config.org_id, event.targetId, binding.agentSessionId);
|
|
838
|
+
}
|
|
839
|
+
catch (err) {
|
|
840
|
+
this.opts.log?.warn(`failed to record session mapping for channel conversation ${event.targetId}: ${String(err)}`);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
698
843
|
if (!inputStepsCreated) {
|
|
699
844
|
// Persist input steps for "earlier events" (batched events that arrived
|
|
700
845
|
// while a dispatch was in flight) inside the in-flight window so a
|
|
@@ -725,6 +870,12 @@ export class ParallAgentGateway {
|
|
|
725
870
|
await this.createInputStep(binding.agentSessionId, event);
|
|
726
871
|
inputStepsCreated = true;
|
|
727
872
|
}
|
|
873
|
+
// Long-turn keepalive: any runtime activity renews the lane lease
|
|
874
|
+
// (throttled in the ledger) so a legitimately long turn is not
|
|
875
|
+
// dethroned at TTL.
|
|
876
|
+
if (activeLane && !this.ledgerDisabled) {
|
|
877
|
+
this.laneLedger?.maybeRenew(activeLane);
|
|
878
|
+
}
|
|
728
879
|
if (captureText && runtimeEvent.type === 'text' && runtimeEvent.text) {
|
|
729
880
|
captureText.push(runtimeEvent.text);
|
|
730
881
|
}
|
|
@@ -745,7 +896,7 @@ export class ParallAgentGateway {
|
|
|
745
896
|
pendingSendCallIds.delete(runtimeEvent.callId)) {
|
|
746
897
|
recordMessageSend(sessionKey, !runtimeEvent.error);
|
|
747
898
|
}
|
|
748
|
-
await this.createRuntimeStep(binding.agentSessionId, event, runtimeEvent, stepIdFilePath, contextFilePath);
|
|
899
|
+
await this.createRuntimeStep(binding.agentSessionId, event, runtimeEvent, stepIdFilePath, contextFilePath, laneContextFilePath);
|
|
749
900
|
}
|
|
750
901
|
if (!binding) {
|
|
751
902
|
binding = this.sessionBindings.get(sessionKey);
|
|
@@ -768,7 +919,7 @@ export class ParallAgentGateway {
|
|
|
768
919
|
await this.createRuntimeStep(binding.agentSessionId, event, {
|
|
769
920
|
type: 'error',
|
|
770
921
|
message: `Dispatch failed: ${String(err)}`,
|
|
771
|
-
}, stepIdFilePath, contextFilePath);
|
|
922
|
+
}, stepIdFilePath, contextFilePath, laneContextFilePath);
|
|
772
923
|
}
|
|
773
924
|
catch (stepErr) {
|
|
774
925
|
if (this.isSessionNotLiveError(stepErr))
|
|
@@ -824,6 +975,9 @@ export class ParallAgentGateway {
|
|
|
824
975
|
else if (stepIdFilePath) {
|
|
825
976
|
this.clearStepIdFile(stepIdFilePath);
|
|
826
977
|
}
|
|
978
|
+
if (laneContextFilePath) {
|
|
979
|
+
this.updateContextFileStepId(laneContextFilePath, null);
|
|
980
|
+
}
|
|
827
981
|
this.inFlightDispatches--;
|
|
828
982
|
if (this.inFlightDispatches === 0 && this.drainResolvers.length > 0) {
|
|
829
983
|
const resolvers = this.drainResolvers.splice(0);
|
|
@@ -889,13 +1043,44 @@ export class ParallAgentGateway {
|
|
|
889
1043
|
item.resolve(false);
|
|
890
1044
|
break;
|
|
891
1045
|
}
|
|
892
|
-
|
|
1046
|
+
// Lane-ledger message events batch per lane identity (chat + thread)
|
|
1047
|
+
// so a fork's claim/steer/complete always addresses one lane.
|
|
1048
|
+
let items;
|
|
1049
|
+
const head = fork.queue[0];
|
|
1050
|
+
if (head && this.usesLaneLedger(head.event)) {
|
|
1051
|
+
const headKey = this.dispatchGroupKey(head.event);
|
|
1052
|
+
const splitAt = fork.queue.findIndex((it) => this.dispatchGroupKey(it.event) !== headKey);
|
|
1053
|
+
items = splitAt === -1 ? fork.queue.splice(0) : fork.queue.splice(0, splitAt);
|
|
1054
|
+
}
|
|
1055
|
+
else {
|
|
1056
|
+
items = fork.queue.splice(0);
|
|
1057
|
+
}
|
|
893
1058
|
const events = items.map((item) => item.event);
|
|
894
1059
|
const last = events[events.length - 1];
|
|
895
1060
|
const earlier = events.slice(0, -1);
|
|
896
1061
|
try {
|
|
897
1062
|
const batchText = [];
|
|
898
|
-
|
|
1063
|
+
let dispatched;
|
|
1064
|
+
if (this.usesLaneLedger(last)) {
|
|
1065
|
+
const outcome = await this.dispatchLaneGroup({
|
|
1066
|
+
events,
|
|
1067
|
+
sessionKey: fork.fork.sessionKey,
|
|
1068
|
+
body: buildForkScopePrefix(last) + buildEventBody(last),
|
|
1069
|
+
earlier,
|
|
1070
|
+
captureText: batchText,
|
|
1071
|
+
hasMoreLocal: () => fork.queue.length > 0,
|
|
1072
|
+
});
|
|
1073
|
+
if (outcome === 'foreign') {
|
|
1074
|
+
// Another pod owns the lane — the events stay pending server-side.
|
|
1075
|
+
for (const item of items)
|
|
1076
|
+
item.resolve(false);
|
|
1077
|
+
break;
|
|
1078
|
+
}
|
|
1079
|
+
dispatched = outcome === 'dispatched';
|
|
1080
|
+
}
|
|
1081
|
+
else {
|
|
1082
|
+
dispatched = await this.runDispatch(last, fork.fork.sessionKey, buildForkScopePrefix(last) + buildEventBody(last), earlier, batchText);
|
|
1083
|
+
}
|
|
899
1084
|
if (!dispatched) {
|
|
900
1085
|
// Shutdown short-circuit — resolve un-acked so the server requeues
|
|
901
1086
|
// for the replacement pod and stop draining further items.
|
|
@@ -1011,9 +1196,10 @@ export class ParallAgentGateway {
|
|
|
1011
1196
|
this.opts.log?.info(`drainMainBuffer halted (shutting down) — ${this.dispatchState.mainBuffer.length} buffered, ${this.dispatchState.pendingForkResults.length} pending fork results left for catch-up`);
|
|
1012
1197
|
break;
|
|
1013
1198
|
}
|
|
1014
|
-
const
|
|
1199
|
+
const groupKey = this.dispatchGroupKey(this.dispatchState.mainBuffer[0]);
|
|
1015
1200
|
const events = [];
|
|
1016
|
-
while (this.dispatchState.mainBuffer[0]
|
|
1201
|
+
while (this.dispatchState.mainBuffer[0] &&
|
|
1202
|
+
this.dispatchGroupKey(this.dispatchState.mainBuffer[0]) === groupKey) {
|
|
1017
1203
|
events.push(this.dispatchState.mainBuffer.shift());
|
|
1018
1204
|
}
|
|
1019
1205
|
const event = events[events.length - 1];
|
|
@@ -1024,7 +1210,41 @@ export class ParallAgentGateway {
|
|
|
1024
1210
|
: this.dispatchState.pendingForkResults.splice(0);
|
|
1025
1211
|
const forkPrefix = buildForkResultPrefix(pendingFork);
|
|
1026
1212
|
this.dispatchState.mainCurrentTargetId = event.targetId;
|
|
1213
|
+
this.mainCurrentGroupKey = groupKey;
|
|
1027
1214
|
this.dispatchState.mainPreDispatchBranchPoint = this.opts.dispatchAdapter.getBranchPoint?.(this.opts.runtimeKey);
|
|
1215
|
+
if (this.usesLaneLedger(event)) {
|
|
1216
|
+
let outcome;
|
|
1217
|
+
try {
|
|
1218
|
+
outcome = await this.dispatchLaneGroup({
|
|
1219
|
+
events,
|
|
1220
|
+
sessionKey: this.opts.runtimeKey,
|
|
1221
|
+
body: forkPrefix + buildEventBody(event),
|
|
1222
|
+
earlier,
|
|
1223
|
+
hasMoreLocal: () => this.dispatchState.mainBuffer.some((e) => this.dispatchGroupKey(e) === groupKey),
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
catch (err) {
|
|
1227
|
+
// The lane was released inside dispatchLaneGroup — members are
|
|
1228
|
+
// pending again server-side; drop them locally and move on.
|
|
1229
|
+
this.opts.log?.error(`lane dispatch failed for ${event.messageId}: ${String(err)}`);
|
|
1230
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1231
|
+
for (const ev of events)
|
|
1232
|
+
this.dispatchedMessages.delete(ev.messageId);
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
if (outcome === 'shutdown') {
|
|
1236
|
+
this.dispatchState.mainBuffer.unshift(...events);
|
|
1237
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1238
|
+
break;
|
|
1239
|
+
}
|
|
1240
|
+
if (outcome === 'foreign') {
|
|
1241
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
// Dispatched — resolution happened server-side (reply cover or
|
|
1245
|
+
// no_action sweep); no legacy acks.
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1028
1248
|
try {
|
|
1029
1249
|
await this.emitDispatchReceived(event);
|
|
1030
1250
|
}
|
|
@@ -1060,6 +1280,7 @@ export class ParallAgentGateway {
|
|
|
1060
1280
|
this.draining = false;
|
|
1061
1281
|
this.dispatchState.mainDispatching = false;
|
|
1062
1282
|
this.dispatchState.mainCurrentTargetId = undefined;
|
|
1283
|
+
this.mainCurrentGroupKey = undefined;
|
|
1063
1284
|
this.dispatchState.mainPreDispatchBranchPoint = undefined;
|
|
1064
1285
|
if (!this.shuttingDown && this.dispatchState.mainBuffer.length > 0) {
|
|
1065
1286
|
// Opportunistic re-drain — best-effort, not a recovery deadline, so it
|
|
@@ -1088,10 +1309,40 @@ export class ParallAgentGateway {
|
|
|
1088
1309
|
const forkPrefix = buildForkResultPrefix(pendingFork);
|
|
1089
1310
|
this.dispatchState.mainDispatching = true;
|
|
1090
1311
|
this.dispatchState.mainCurrentTargetId = event.targetId;
|
|
1312
|
+
this.mainCurrentGroupKey = this.dispatchGroupKey(event);
|
|
1091
1313
|
// Snapshot the on-disk branch point BEFORE runDispatch starts writing
|
|
1092
1314
|
// to the session file. Fork sessions created while main is in-flight
|
|
1093
1315
|
// use this to branch from the clean pre-dispatch state.
|
|
1094
1316
|
this.dispatchState.mainPreDispatchBranchPoint = this.opts.dispatchAdapter.getBranchPoint?.(this.opts.runtimeKey);
|
|
1317
|
+
if (this.usesLaneLedger(event)) {
|
|
1318
|
+
// Ledger flow: claim replaces mark-received; complete/reply replace
|
|
1319
|
+
// acks. A foreign incumbent leaves the event pending for re-drive.
|
|
1320
|
+
let outcome = 'shutdown';
|
|
1321
|
+
try {
|
|
1322
|
+
try {
|
|
1323
|
+
outcome = await this.dispatchLaneGroup({
|
|
1324
|
+
events: [event],
|
|
1325
|
+
sessionKey: this.opts.runtimeKey,
|
|
1326
|
+
body: forkPrefix + buildEventBody(event),
|
|
1327
|
+
earlier: [],
|
|
1328
|
+
hasMoreLocal: () => this.dispatchState.mainBuffer.some((e) => this.dispatchGroupKey(e) === this.dispatchGroupKey(event)),
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
catch (err) {
|
|
1332
|
+
// Same failure contract as the buffered-group path: accumulated
|
|
1333
|
+
// fork results must survive a failed turn for later replay.
|
|
1334
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1335
|
+
throw err;
|
|
1336
|
+
}
|
|
1337
|
+
if (outcome !== 'dispatched') {
|
|
1338
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
finally {
|
|
1342
|
+
await this.drainMainBuffer();
|
|
1343
|
+
}
|
|
1344
|
+
return outcome === 'dispatched';
|
|
1345
|
+
}
|
|
1095
1346
|
try {
|
|
1096
1347
|
await this.emitDispatchReceived(event);
|
|
1097
1348
|
}
|
|
@@ -1099,6 +1350,7 @@ export class ParallAgentGateway {
|
|
|
1099
1350
|
this.opts.log?.warn?.(`mark-received failed, leaving unacked for retry: ${String(err)}`);
|
|
1100
1351
|
this.dispatchState.mainDispatching = false;
|
|
1101
1352
|
this.dispatchState.mainCurrentTargetId = undefined;
|
|
1353
|
+
this.mainCurrentGroupKey = undefined;
|
|
1102
1354
|
this.dispatchState.mainPreDispatchBranchPoint = undefined;
|
|
1103
1355
|
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1104
1356
|
return false;
|
|
@@ -1125,7 +1377,20 @@ export class ParallAgentGateway {
|
|
|
1125
1377
|
// arrival order is preserved and the event cannot be orphaned in a
|
|
1126
1378
|
// gap between the steer await and the push.
|
|
1127
1379
|
this.dispatchState.mainBuffer.push(event);
|
|
1128
|
-
if (this.
|
|
1380
|
+
if (this.usesLaneLedger(event)) {
|
|
1381
|
+
// Ledger flow: fold into the live lane server-side FIRST, then
|
|
1382
|
+
// inject. An un-folded injection is forbidden (the pending WorkItem
|
|
1383
|
+
// would re-drive after complete and be handled twice); a failed
|
|
1384
|
+
// fold leaves the event buffered — the drain claims it as its own
|
|
1385
|
+
// turn. Injection requires an exact lane match (same chat AND same
|
|
1386
|
+
// thread) — a thread message never rides a channel turn.
|
|
1387
|
+
if (this.mainCurrentGroupKey === this.dispatchGroupKey(event) &&
|
|
1388
|
+
(await this.laneLedger?.steerLive(event)) &&
|
|
1389
|
+
(await this.opts.dispatchAdapter.enqueueDuringDispatch?.(this.opts.runtimeKey, buildEventBody(event)))) {
|
|
1390
|
+
this.opts.log?.info(`steer folded+injected for ${event.messageId} (will drain for bookkeeping)`);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
else if (this.dispatchState.mainCurrentTargetId === event.targetId &&
|
|
1129
1394
|
(await this.opts.dispatchAdapter.enqueueDuringDispatch?.(this.opts.runtimeKey, buildEventBody(event)))) {
|
|
1130
1395
|
this.opts.log?.info(`steer injected for ${event.messageId} (will drain for bookkeeping)`);
|
|
1131
1396
|
}
|
|
@@ -1311,9 +1576,13 @@ export class ParallAgentGateway {
|
|
|
1311
1576
|
try {
|
|
1312
1577
|
const dispatched = await this.handleInboundEvent(event);
|
|
1313
1578
|
if (dispatched) {
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1579
|
+
// Ledger events resolve server-side (reply cover / no_action sweep);
|
|
1580
|
+
// the legacy by-source ack is only for non-ledger runtimes.
|
|
1581
|
+
if (!this.usesLaneLedger(event)) {
|
|
1582
|
+
this.opts.client
|
|
1583
|
+
.ackDispatch(this.opts.config.org_id, { source_type: 'message', source_id: data.id })
|
|
1584
|
+
.catch(() => { });
|
|
1585
|
+
}
|
|
1317
1586
|
}
|
|
1318
1587
|
else {
|
|
1319
1588
|
this.dispatchedMessages.delete(data.id);
|
|
@@ -1324,7 +1593,21 @@ export class ParallAgentGateway {
|
|
|
1324
1593
|
this.dispatchedMessages.delete(data.id);
|
|
1325
1594
|
}
|
|
1326
1595
|
}
|
|
1327
|
-
|
|
1596
|
+
// Ledger re-drive consumption: dispatch.new message hints re-enter the
|
|
1597
|
+
// shared WorkItem consumption path (same protocol as catch-up).
|
|
1598
|
+
async handleMessageRedrive(item) {
|
|
1599
|
+
if (!item.chat_id || !item.source_id)
|
|
1600
|
+
return;
|
|
1601
|
+
await this.consumeMessageWorkItem({
|
|
1602
|
+
id: item.id,
|
|
1603
|
+
source_id: item.source_id,
|
|
1604
|
+
chat_id: item.chat_id,
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
consumeMessageWorkItem(item) {
|
|
1608
|
+
return consumeMessageWorkItem(this.laneFlowHost(), item);
|
|
1609
|
+
}
|
|
1610
|
+
async handleTaskAssignment(task, ackSourceId, dispatchEventId) {
|
|
1328
1611
|
if (this.shuttingDown)
|
|
1329
1612
|
return false; // drain window — let server requeue via catch-up
|
|
1330
1613
|
const dedupeKey = `${task.id}:${task.updated_at}`;
|
|
@@ -1354,6 +1637,7 @@ export class ParallAgentGateway {
|
|
|
1354
1637
|
sentAt: task.updated_at ?? task.created_at,
|
|
1355
1638
|
ackSourceType: 'task_activity',
|
|
1356
1639
|
ackSourceId,
|
|
1640
|
+
dispatchEventId,
|
|
1357
1641
|
};
|
|
1358
1642
|
const dispatched = await this.handleInboundEvent(event);
|
|
1359
1643
|
if (!dispatched) {
|
|
@@ -1378,7 +1662,7 @@ export class ParallAgentGateway {
|
|
|
1378
1662
|
this.opts.log?.info(`skipping stale task dispatch ${ackSourceId ?? taskId} — assigned to ${task.assignee_id}, creator ${task.creator_id}`);
|
|
1379
1663
|
return true;
|
|
1380
1664
|
}
|
|
1381
|
-
return this.handleTaskAssignment(task, ackSourceId);
|
|
1665
|
+
return this.handleTaskAssignment(task, ackSourceId, opts.dispatchEventId);
|
|
1382
1666
|
}
|
|
1383
1667
|
async handleTaskComment(commentId, taskId, actorId, deliveryReason) {
|
|
1384
1668
|
if (this.shuttingDown)
|
|
@@ -1612,6 +1896,88 @@ export class ParallAgentGateway {
|
|
|
1612
1896
|
return true;
|
|
1613
1897
|
return this.handleExternalTriggerRun(run);
|
|
1614
1898
|
}
|
|
1899
|
+
// fetchAndHandleChannelMessage resolves a channel_message dispatch to its
|
|
1900
|
+
// durable ChannelMessage + conversation and hands it to the inbound
|
|
1901
|
+
// pipeline. targetId = the ChannelConversation id, so per-conversation
|
|
1902
|
+
// multi-turn continuity rides the same per-target session mechanics as
|
|
1903
|
+
// chats. Design: docs/engineering-design/external-im-channel-design.md.
|
|
1904
|
+
async fetchAndHandleChannelMessage(messageId) {
|
|
1905
|
+
if (this.shuttingDown)
|
|
1906
|
+
return false;
|
|
1907
|
+
// Capped dedupe (the chat-message path, not the unbounded task set): a busy
|
|
1908
|
+
// external IM conversation would otherwise retain one key per message ever
|
|
1909
|
+
// handled on a long-lived agent. On a RETRYABLE failure the claim is
|
|
1910
|
+
// released so a later dispatch.new / catch-up re-fetches (matching the
|
|
1911
|
+
// chat-message path); a 404/stale result keeps the claim and acks.
|
|
1912
|
+
const claimKey = `channel_message:${messageId}`;
|
|
1913
|
+
if (!this.tryClaimMessage(claimKey))
|
|
1914
|
+
return false;
|
|
1915
|
+
let msg = null;
|
|
1916
|
+
let conv = null;
|
|
1917
|
+
try {
|
|
1918
|
+
msg = await this.opts.client.getChannelMessage(this.opts.config.org_id, messageId);
|
|
1919
|
+
conv = await this.opts.client.getChannelConversation(this.opts.config.org_id, msg.conversation_id);
|
|
1920
|
+
}
|
|
1921
|
+
catch (err) {
|
|
1922
|
+
const status = err?.status;
|
|
1923
|
+
if (status === 404) {
|
|
1924
|
+
this.opts.log?.warn(`channel message ${messageId} not accessible (404), acking stale dispatch`);
|
|
1925
|
+
return true;
|
|
1926
|
+
}
|
|
1927
|
+
this.dispatchedMessages.delete(claimKey);
|
|
1928
|
+
this.opts.log?.warn(`channel message fetch failed for ${messageId}, leaving pending: ${String(err)}`);
|
|
1929
|
+
return false;
|
|
1930
|
+
}
|
|
1931
|
+
if (!msg || !conv) {
|
|
1932
|
+
return true;
|
|
1933
|
+
}
|
|
1934
|
+
this.opts.log?.info(`channel message: ${msg.id} (conversation ${conv.id})`);
|
|
1935
|
+
// Resolve the provider from the conversation's connection for prompt
|
|
1936
|
+
// labeling + the reply-clip hint. The connection id → provider mapping
|
|
1937
|
+
// is stable, so a tiny cache avoids one fetch per message.
|
|
1938
|
+
let provider = this.channelConnectionProviders.get(conv.connection_id);
|
|
1939
|
+
if (!provider) {
|
|
1940
|
+
try {
|
|
1941
|
+
const connection = await this.opts.client.getChannelConnection(this.opts.config.org_id, conv.connection_id);
|
|
1942
|
+
provider = connection.provider;
|
|
1943
|
+
this.channelConnectionProviders.set(conv.connection_id, provider);
|
|
1944
|
+
}
|
|
1945
|
+
catch {
|
|
1946
|
+
provider = undefined; // label degrades; reply hint still names the clip generically
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
const event = {
|
|
1950
|
+
type: 'channel_message',
|
|
1951
|
+
targetId: conv.id,
|
|
1952
|
+
targetName: conv.external_user_name || conv.external_conversation_id,
|
|
1953
|
+
targetType: 'channel_conversation',
|
|
1954
|
+
senderId: msg.external_user_id || 'external',
|
|
1955
|
+
senderName: msg.external_user_name || msg.external_user_id || 'external user',
|
|
1956
|
+
messageId: msg.id,
|
|
1957
|
+
body: msg.text,
|
|
1958
|
+
sentAt: msg.received_at,
|
|
1959
|
+
channelProvider: provider,
|
|
1960
|
+
channelConversationType: conv.conversation_type || undefined,
|
|
1961
|
+
channelExternalConversationId: conv.external_conversation_id,
|
|
1962
|
+
channelExternalMessageId: msg.external_message_id,
|
|
1963
|
+
ackSourceType: 'channel_message',
|
|
1964
|
+
ackSourceId: msg.id,
|
|
1965
|
+
};
|
|
1966
|
+
// Release the claim if the event isn't actually dispatched (or throws) so
|
|
1967
|
+
// a retry can re-attempt — same contract as the chat-message path.
|
|
1968
|
+
let dispatched;
|
|
1969
|
+
try {
|
|
1970
|
+
dispatched = await this.handleInboundEvent(event);
|
|
1971
|
+
}
|
|
1972
|
+
catch (err) {
|
|
1973
|
+
this.dispatchedMessages.delete(claimKey);
|
|
1974
|
+
throw err;
|
|
1975
|
+
}
|
|
1976
|
+
if (!dispatched) {
|
|
1977
|
+
this.dispatchedMessages.delete(claimKey);
|
|
1978
|
+
}
|
|
1979
|
+
return dispatched;
|
|
1980
|
+
}
|
|
1615
1981
|
async handleExternalTriggerRun(run) {
|
|
1616
1982
|
if (this.shuttingDown)
|
|
1617
1983
|
return false;
|
|
@@ -1749,10 +2115,14 @@ export class ParallAgentGateway {
|
|
|
1749
2115
|
}
|
|
1750
2116
|
processed++;
|
|
1751
2117
|
try {
|
|
1752
|
-
|
|
2118
|
+
const ackItem = () => {
|
|
2119
|
+
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
|
|
2120
|
+
};
|
|
1753
2121
|
if (item.event_type === 'task_assign' && item.task_id) {
|
|
1754
2122
|
try {
|
|
1755
|
-
|
|
2123
|
+
await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.handleTaskDispatch(item.task_id ?? '', item.source_id ?? item.task_id ?? '', {
|
|
2124
|
+
dispatchEventId,
|
|
2125
|
+
}), ackItem);
|
|
1756
2126
|
}
|
|
1757
2127
|
catch (err) {
|
|
1758
2128
|
this.opts.log?.warn(`catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
|
|
@@ -1761,7 +2131,10 @@ export class ParallAgentGateway {
|
|
|
1761
2131
|
}
|
|
1762
2132
|
else if (item.event_type === 'task_update' && item.task_id) {
|
|
1763
2133
|
try {
|
|
1764
|
-
|
|
2134
|
+
await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.handleTaskDispatch(item.task_id ?? '', item.source_id ?? item.task_id ?? '', {
|
|
2135
|
+
allowCreator: true,
|
|
2136
|
+
dispatchEventId,
|
|
2137
|
+
}), ackItem);
|
|
1765
2138
|
}
|
|
1766
2139
|
catch (err) {
|
|
1767
2140
|
this.opts.log?.warn(`catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
|
|
@@ -1769,61 +2142,29 @@ export class ParallAgentGateway {
|
|
|
1769
2142
|
}
|
|
1770
2143
|
}
|
|
1771
2144
|
else if (item.event_type === 'task_comment' && item.source_id && item.task_id) {
|
|
1772
|
-
|
|
2145
|
+
await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.handleTaskComment(item.source_id, item.task_id ?? '', item.actor_id, item.delivery_reason), ackItem);
|
|
1773
2146
|
}
|
|
1774
2147
|
else if (item.event_type === 'wiki_comment' && item.source_id) {
|
|
1775
|
-
|
|
2148
|
+
await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.handleWikiComment(item.source_id, item.actor_id, item.delivery_reason), ackItem);
|
|
1776
2149
|
}
|
|
1777
2150
|
else if (item.event_type === 'schedule.fire' && item.source_id) {
|
|
1778
|
-
|
|
2151
|
+
await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleScheduleFire(item.source_id, item.actor_id), ackItem);
|
|
1779
2152
|
}
|
|
1780
2153
|
else if (item.event_type === 'external_trigger' && item.source_id) {
|
|
1781
|
-
|
|
2154
|
+
await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleExternalTriggerRun(item.source_id), ackItem);
|
|
2155
|
+
}
|
|
2156
|
+
else if (item.event_type === 'channel_message' && item.source_id) {
|
|
2157
|
+
await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleChannelMessage(item.source_id), ackItem);
|
|
1782
2158
|
}
|
|
1783
2159
|
else if (item.event_type === 'approval_decided' && item.source_id) {
|
|
1784
|
-
|
|
2160
|
+
await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleApprovalDecided(item.source_id, item.actor_id, item.chat_id ?? null), ackItem);
|
|
1785
2161
|
}
|
|
1786
2162
|
else if (item.event_type === 'message' && item.source_id && item.chat_id) {
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
msg = await this.opts.client.getMessage(item.source_id);
|
|
1793
|
-
}
|
|
1794
|
-
catch (err) {
|
|
1795
|
-
const status = err?.status;
|
|
1796
|
-
if (status === 404) {
|
|
1797
|
-
msg = null;
|
|
1798
|
-
}
|
|
1799
|
-
else {
|
|
1800
|
-
msgFetchFailed = true;
|
|
1801
|
-
this.opts.log?.warn(`catch-up message fetch failed for ${item.source_id}, leaving pending: ${String(err)}`);
|
|
1802
|
-
}
|
|
1803
|
-
}
|
|
1804
|
-
if (msgFetchFailed) {
|
|
1805
|
-
this.dispatchedMessages.delete(item.source_id);
|
|
1806
|
-
continue;
|
|
1807
|
-
}
|
|
1808
|
-
if (!msg || msg.sender_id === this.opts.agentUserId) {
|
|
1809
|
-
this.dispatchedMessages.delete(item.source_id);
|
|
1810
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
|
|
1811
|
-
continue;
|
|
1812
|
-
}
|
|
1813
|
-
const decision = await this.buildMessageDispatchDecision(item.chat_id, msg);
|
|
1814
|
-
if (decision.action === 'retry') {
|
|
1815
|
-
this.dispatchedMessages.delete(item.source_id);
|
|
1816
|
-
continue;
|
|
1817
|
-
}
|
|
1818
|
-
if (decision.action === 'skip') {
|
|
1819
|
-
this.dispatchedMessages.delete(item.source_id);
|
|
1820
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
|
|
1821
|
-
continue;
|
|
1822
|
-
}
|
|
1823
|
-
dispatched = await this.handleInboundEvent(decision.event);
|
|
1824
|
-
}
|
|
1825
|
-
if (dispatched) {
|
|
1826
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
|
|
2163
|
+
await this.consumeMessageWorkItem({
|
|
2164
|
+
id: item.id,
|
|
2165
|
+
source_id: item.source_id,
|
|
2166
|
+
chat_id: item.chat_id,
|
|
2167
|
+
});
|
|
1827
2168
|
}
|
|
1828
2169
|
}
|
|
1829
2170
|
catch (err) {
|
|
@@ -1892,6 +2233,20 @@ export class ParallAgentGateway {
|
|
|
1892
2233
|
this.abortFork(targetId, 'ws reconnect');
|
|
1893
2234
|
}
|
|
1894
2235
|
}
|
|
2236
|
+
// Reconnect with nothing in flight: interrupted turns can't resume, so
|
|
2237
|
+
// hand their lane members back to the pending pool before catch-up
|
|
2238
|
+
// re-claims (an in-flight turn keeps its lanes — it is still the owner).
|
|
2239
|
+
if (this.laneLedger && this.inFlightDispatches === 0 && this.laneLedger.activeCount > 0) {
|
|
2240
|
+
log?.info(`releasing ${this.laneLedger.activeCount} stale lane(s) on reconnect`);
|
|
2241
|
+
await this.laneLedger.releaseAll();
|
|
2242
|
+
}
|
|
2243
|
+
// Re-probe the ledger each connection: a sticky downgrade from a
|
|
2244
|
+
// transient edge 404 during a rolling deploy must not outlive the
|
|
2245
|
+
// connection that observed it.
|
|
2246
|
+
if (this.laneLedger && this.ledgerDisabled) {
|
|
2247
|
+
log?.info('re-probing dispatch ledger after reconnect (was disabled)');
|
|
2248
|
+
this.ledgerDisabled = false;
|
|
2249
|
+
}
|
|
1895
2250
|
const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
|
|
1896
2251
|
try {
|
|
1897
2252
|
const count = await fetchAllChats(client, config.org_id, this.chatInfoMap);
|
|
@@ -1971,6 +2326,13 @@ export class ParallAgentGateway {
|
|
|
1971
2326
|
}
|
|
1972
2327
|
if (this.heartbeatTimer)
|
|
1973
2328
|
clearInterval(this.heartbeatTimer);
|
|
2329
|
+
// Completed turns already released their lanes; whatever is left belongs
|
|
2330
|
+
// to interrupted work — hand the members back so the replacement pod
|
|
2331
|
+
// re-claims immediately instead of waiting out the lease.
|
|
2332
|
+
if (this.laneLedger && this.laneLedger.activeCount > 0) {
|
|
2333
|
+
this.opts.log?.info(`releasing ${this.laneLedger.activeCount} lane(s) on shutdown`);
|
|
2334
|
+
await this.laneLedger.releaseAll();
|
|
2335
|
+
}
|
|
1974
2336
|
await this.opts.onBeforeDisconnect?.();
|
|
1975
2337
|
this.opts.ws.disconnect();
|
|
1976
2338
|
this.opts.log?.info(`disconnected`);
|