@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/src/gateway-base.ts
CHANGED
|
@@ -7,6 +7,8 @@ import type {
|
|
|
7
7
|
AgentNewSessionData,
|
|
8
8
|
AgentSessionDB,
|
|
9
9
|
Approval,
|
|
10
|
+
ChannelConversation,
|
|
11
|
+
ChannelMessage,
|
|
10
12
|
Chat,
|
|
11
13
|
ChatUpdateData,
|
|
12
14
|
Comment,
|
|
@@ -34,6 +36,13 @@ import type {
|
|
|
34
36
|
GatewayLogger,
|
|
35
37
|
RuntimeEvent,
|
|
36
38
|
} from './dispatch-adapter.js';
|
|
39
|
+
import {
|
|
40
|
+
consumeMessageWorkItem,
|
|
41
|
+
consumeTypedDispatch,
|
|
42
|
+
dispatchLaneGroup,
|
|
43
|
+
} from './gateway-lane-flow.js';
|
|
44
|
+
import type { LaneFlowHost } from './gateway-lane-flow.js';
|
|
45
|
+
import { LaneLedger } from './lane-ledger.js';
|
|
37
46
|
import { routeTrigger } from './routing.js';
|
|
38
47
|
import {
|
|
39
48
|
clearDispatchMessageId,
|
|
@@ -87,7 +96,7 @@ type ActiveForkState = {
|
|
|
87
96
|
deadlineExceeded: boolean;
|
|
88
97
|
};
|
|
89
98
|
|
|
90
|
-
type DispatchableMessage = {
|
|
99
|
+
export type DispatchableMessage = {
|
|
91
100
|
id: string;
|
|
92
101
|
sender_id: string;
|
|
93
102
|
sender?: { display_name?: string | null };
|
|
@@ -99,7 +108,7 @@ type DispatchableMessage = {
|
|
|
99
108
|
created_at?: string;
|
|
100
109
|
};
|
|
101
110
|
|
|
102
|
-
type MessageDispatchDecision =
|
|
111
|
+
export type MessageDispatchDecision =
|
|
103
112
|
| { action: 'dispatch'; event: ParallEvent }
|
|
104
113
|
| { action: 'skip' }
|
|
105
114
|
| { action: 'retry' };
|
|
@@ -131,6 +140,13 @@ export type ParallGatewayOptions = {
|
|
|
131
140
|
contextFilePathForSession?: (sessionKey: string) => string | undefined;
|
|
132
141
|
/** @deprecated Use contextFilePathForSession. Kept for runtimes that haven't migrated. */
|
|
133
142
|
stepIdFilePathForSession?: (sessionKey: string) => string | undefined;
|
|
143
|
+
/**
|
|
144
|
+
* PRLL_CONTEXT_DIR contract root (per-agent stateDir, never the workspace).
|
|
145
|
+
* When set, chat-message dispatches ride the server dispatch ledger
|
|
146
|
+
* (claim → steer → complete) and per-lane context files are written under
|
|
147
|
+
* this directory. Absent → legacy received/ack flow (openclaw / hermes).
|
|
148
|
+
*/
|
|
149
|
+
dispatchContextDir?: string;
|
|
134
150
|
onConfigUpdate?: (data: AgentConfigUpdateData) => Promise<void> | void;
|
|
135
151
|
onSessionReady?: (state: {
|
|
136
152
|
activeSessionId?: string;
|
|
@@ -198,6 +214,9 @@ function resolveStepTarget(event: ParallEvent): { target_type: string; target_id
|
|
|
198
214
|
if (event.type === 'external_trigger' || event.targetId.startsWith('xtr_')) {
|
|
199
215
|
return { target_type: 'external_trigger', target_id: event.targetId };
|
|
200
216
|
}
|
|
217
|
+
if (event.type === 'channel_message' || event.targetId.startsWith('chv_')) {
|
|
218
|
+
return { target_type: 'channel_conversation', target_id: event.targetId };
|
|
219
|
+
}
|
|
201
220
|
if (event.type === 'wiki_comment') {
|
|
202
221
|
// target_id is the full wiki target_uri (scheme-stripped routing key). The
|
|
203
222
|
// server stores target_type freely and only publishes step WS events /
|
|
@@ -269,6 +288,9 @@ async function fetchAllChats(
|
|
|
269
288
|
export class ParallAgentGateway {
|
|
270
289
|
private readonly chatInfoMap = new Map<string, ChatInfo>();
|
|
271
290
|
private readonly dispatchedTasks = new Set<string>();
|
|
291
|
+
// connection id → provider alias, for channel_message prompt labeling
|
|
292
|
+
// (stable mapping; avoids one connection fetch per inbound message).
|
|
293
|
+
private readonly channelConnectionProviders = new Map<string, string>();
|
|
272
294
|
private readonly dispatchedMessages = new Set<string>();
|
|
273
295
|
private readonly forkStates = new Map<string, ActiveForkState>();
|
|
274
296
|
private readonly dispatchState: DispatchState = {
|
|
@@ -295,6 +317,15 @@ export class ParallAgentGateway {
|
|
|
295
317
|
private drainResolvers: Array<() => void> = [];
|
|
296
318
|
private pendingRestartNotification: string | null = null;
|
|
297
319
|
|
|
320
|
+
private readonly laneLedger?: LaneLedger;
|
|
321
|
+
// Sticky fallback: flipped when the server predates the ledger (claim
|
|
322
|
+
// endpoint 404) so every subsequent dispatch uses the legacy flow.
|
|
323
|
+
private ledgerDisabled = false;
|
|
324
|
+
// Group key of the group currently being dispatched on main — lane-aware
|
|
325
|
+
// (targetId + thread), unlike mainCurrentTargetId which stays chat-level
|
|
326
|
+
// for fork routing decisions.
|
|
327
|
+
private mainCurrentGroupKey?: string;
|
|
328
|
+
|
|
298
329
|
private readonly DISPATCHED_MESSAGES_CAP = 5000;
|
|
299
330
|
// SHUTDOWN_DEADLINE_MS is read by waitForDrain via the configured value
|
|
300
331
|
// below — kept as instance state so per-runtime configs can override it
|
|
@@ -304,6 +335,14 @@ export class ParallAgentGateway {
|
|
|
304
335
|
private readonly DISPATCH_DEADLINE_MS: number;
|
|
305
336
|
|
|
306
337
|
constructor(private readonly opts: ParallGatewayOptions) {
|
|
338
|
+
if (opts.dispatchContextDir) {
|
|
339
|
+
this.laneLedger = new LaneLedger({
|
|
340
|
+
client: opts.client,
|
|
341
|
+
orgId: opts.config.org_id,
|
|
342
|
+
contextDir: opts.dispatchContextDir,
|
|
343
|
+
log: opts.log,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
307
346
|
this.SHUTDOWN_DEADLINE_MS = opts.shutdownDeadlineMs ?? 60_000;
|
|
308
347
|
this.FORK_DEADLINE_MS = opts.forkDeadlineMs ?? 2 * 60 * 60_000;
|
|
309
348
|
this.DISPATCH_DEADLINE_MS = opts.dispatchDeadlineMs ?? 20 * 60_000;
|
|
@@ -387,15 +426,30 @@ export class ParallAgentGateway {
|
|
|
387
426
|
if (data.assignee_id !== this.opts.agentUserId) return;
|
|
388
427
|
if (data.status !== 'todo' && data.status !== 'in_progress') return;
|
|
389
428
|
try {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
.
|
|
398
|
-
|
|
429
|
+
// Prefer the exact WorkItem id the server threads through the event —
|
|
430
|
+
// a task PATCH can enqueue sibling task_assign + task_update rows
|
|
431
|
+
// under the same (task_activity, task_id) source tuple, and source-
|
|
432
|
+
// level claim/ack would consume or clear the wrong sibling.
|
|
433
|
+
await this.consumeTypedDispatch(
|
|
434
|
+
data.dispatch_event_id
|
|
435
|
+
? { dispatchEventId: data.dispatch_event_id }
|
|
436
|
+
: { sourceType: 'task_activity', sourceId: data.id },
|
|
437
|
+
(dispatchEventId) => this.handleTaskAssignment(data, data.id, dispatchEventId),
|
|
438
|
+
(dispatchEventId) => {
|
|
439
|
+
if (dispatchEventId) {
|
|
440
|
+
this.opts.client
|
|
441
|
+
.ackDispatchByID(this.opts.config.org_id, dispatchEventId)
|
|
442
|
+
.catch(() => {});
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
this.opts.client
|
|
446
|
+
.ackDispatch(this.opts.config.org_id, {
|
|
447
|
+
source_type: 'task_activity',
|
|
448
|
+
source_id: data.id,
|
|
449
|
+
})
|
|
450
|
+
.catch(() => {});
|
|
451
|
+
},
|
|
452
|
+
);
|
|
399
453
|
} catch (err) {
|
|
400
454
|
this.opts.log?.error(`task dispatch failed for ${data.id}: ${String(err)}`);
|
|
401
455
|
}
|
|
@@ -405,15 +459,19 @@ export class ParallAgentGateway {
|
|
|
405
459
|
if (data.event_type === 'task_comment') {
|
|
406
460
|
if (!data.source_id || !data.task_id) return;
|
|
407
461
|
try {
|
|
408
|
-
|
|
409
|
-
data.
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
462
|
+
await this.consumeTypedDispatch(
|
|
463
|
+
{ dispatchEventId: data.id },
|
|
464
|
+
() =>
|
|
465
|
+
this.handleTaskComment(
|
|
466
|
+
data.source_id,
|
|
467
|
+
data.task_id ?? '',
|
|
468
|
+
data.actor_id,
|
|
469
|
+
data.delivery_reason,
|
|
470
|
+
),
|
|
471
|
+
() => {
|
|
472
|
+
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
473
|
+
},
|
|
413
474
|
);
|
|
414
|
-
if (dispatched) {
|
|
415
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
416
|
-
}
|
|
417
475
|
} catch (err) {
|
|
418
476
|
this.opts.log?.error(
|
|
419
477
|
`task comment dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
@@ -422,14 +480,13 @@ export class ParallAgentGateway {
|
|
|
422
480
|
} else if (data.event_type === 'wiki_comment') {
|
|
423
481
|
if (!data.source_id) return;
|
|
424
482
|
try {
|
|
425
|
-
|
|
426
|
-
data.
|
|
427
|
-
data.actor_id,
|
|
428
|
-
|
|
483
|
+
await this.consumeTypedDispatch(
|
|
484
|
+
{ dispatchEventId: data.id },
|
|
485
|
+
() => this.handleWikiComment(data.source_id, data.actor_id, data.delivery_reason),
|
|
486
|
+
() => {
|
|
487
|
+
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
488
|
+
},
|
|
429
489
|
);
|
|
430
|
-
if (dispatched) {
|
|
431
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
432
|
-
}
|
|
433
490
|
} catch (err) {
|
|
434
491
|
this.opts.log?.error(
|
|
435
492
|
`wiki comment dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
@@ -438,24 +495,30 @@ export class ParallAgentGateway {
|
|
|
438
495
|
} else if (data.event_type === 'task_update') {
|
|
439
496
|
if (!data.task_id) return;
|
|
440
497
|
try {
|
|
441
|
-
|
|
442
|
-
data.
|
|
443
|
-
|
|
444
|
-
|
|
498
|
+
await this.consumeTypedDispatch(
|
|
499
|
+
{ dispatchEventId: data.id },
|
|
500
|
+
(dispatchEventId) =>
|
|
501
|
+
this.handleTaskDispatch(data.task_id ?? '', data.source_id ?? data.task_id ?? '', {
|
|
502
|
+
allowCreator: true,
|
|
503
|
+
dispatchEventId,
|
|
504
|
+
}),
|
|
505
|
+
() => {
|
|
506
|
+
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
507
|
+
},
|
|
445
508
|
);
|
|
446
|
-
if (dispatched) {
|
|
447
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
448
|
-
}
|
|
449
509
|
} catch (err) {
|
|
450
510
|
this.opts.log?.error(`task update dispatch failed for ${data.task_id}: ${String(err)}`);
|
|
451
511
|
}
|
|
452
512
|
} else if (data.event_type === 'schedule.fire') {
|
|
453
513
|
if (!data.source_id) return;
|
|
454
514
|
try {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
this.
|
|
458
|
-
|
|
515
|
+
await this.consumeTypedDispatch(
|
|
516
|
+
{ dispatchEventId: data.id },
|
|
517
|
+
() => this.fetchAndHandleScheduleFire(data.source_id, data.actor_id),
|
|
518
|
+
() => {
|
|
519
|
+
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
520
|
+
},
|
|
521
|
+
);
|
|
459
522
|
} catch (err) {
|
|
460
523
|
this.opts.log?.error(
|
|
461
524
|
`schedule fire dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
@@ -464,31 +527,67 @@ export class ParallAgentGateway {
|
|
|
464
527
|
} else if (data.event_type === 'external_trigger') {
|
|
465
528
|
if (!data.source_id) return;
|
|
466
529
|
try {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
this.
|
|
470
|
-
|
|
530
|
+
await this.consumeTypedDispatch(
|
|
531
|
+
{ dispatchEventId: data.id },
|
|
532
|
+
() => this.fetchAndHandleExternalTriggerRun(data.source_id),
|
|
533
|
+
() => {
|
|
534
|
+
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
535
|
+
},
|
|
536
|
+
);
|
|
471
537
|
} catch (err) {
|
|
472
538
|
this.opts.log?.error(
|
|
473
539
|
`external trigger dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
474
540
|
);
|
|
475
541
|
}
|
|
542
|
+
} else if (data.event_type === 'channel_message') {
|
|
543
|
+
if (!data.source_id) return;
|
|
544
|
+
try {
|
|
545
|
+
await this.consumeTypedDispatch(
|
|
546
|
+
{ dispatchEventId: data.id },
|
|
547
|
+
() => this.fetchAndHandleChannelMessage(data.source_id),
|
|
548
|
+
() => {
|
|
549
|
+
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
550
|
+
},
|
|
551
|
+
);
|
|
552
|
+
} catch (err) {
|
|
553
|
+
this.opts.log?.error(
|
|
554
|
+
`channel message dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
476
557
|
} else if (data.event_type === 'approval_decided') {
|
|
477
558
|
if (!data.source_id) return;
|
|
478
559
|
try {
|
|
479
|
-
|
|
480
|
-
data.
|
|
481
|
-
|
|
482
|
-
|
|
560
|
+
await this.consumeTypedDispatch(
|
|
561
|
+
{ dispatchEventId: data.id },
|
|
562
|
+
() =>
|
|
563
|
+
this.fetchAndHandleApprovalDecided(
|
|
564
|
+
data.source_id,
|
|
565
|
+
data.actor_id,
|
|
566
|
+
data.chat_id ?? null,
|
|
567
|
+
),
|
|
568
|
+
() => {
|
|
569
|
+
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
570
|
+
},
|
|
483
571
|
);
|
|
484
|
-
if (dispatched) {
|
|
485
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
486
|
-
}
|
|
487
572
|
} catch (err) {
|
|
488
573
|
this.opts.log?.error(
|
|
489
574
|
`approval decided dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
490
575
|
);
|
|
491
576
|
}
|
|
577
|
+
} else if (
|
|
578
|
+
data.event_type === 'message' &&
|
|
579
|
+
this.laneLedger &&
|
|
580
|
+
data.source_id &&
|
|
581
|
+
data.chat_id
|
|
582
|
+
) {
|
|
583
|
+
// Ledger re-drive hint: a pending message WorkItem re-published after
|
|
584
|
+
// a same-target lane completed (claim previously refused, or a steer
|
|
585
|
+
// failed). Live first delivery stays on the message.new handler.
|
|
586
|
+
try {
|
|
587
|
+
await this.handleMessageRedrive(data);
|
|
588
|
+
} catch (err) {
|
|
589
|
+
this.opts.log?.error(`message re-drive failed for ${data.source_id}: ${String(err)}`);
|
|
590
|
+
}
|
|
492
591
|
} else if (data.event_type !== 'message' && data.event_type !== 'task_assign') {
|
|
493
592
|
// Truly unknown event_type — log so a newly-added dispatch type
|
|
494
593
|
// not yet wired here surfaces during runtime testing. "message"
|
|
@@ -536,6 +635,61 @@ export class ParallAgentGateway {
|
|
|
536
635
|
});
|
|
537
636
|
}
|
|
538
637
|
|
|
638
|
+
/** True when this event's lifecycle is owned by the dispatch lane ledger. */
|
|
639
|
+
private usesLaneLedger(event: ParallEvent): boolean {
|
|
640
|
+
return this.laneLedger != null && !this.ledgerDisabled && this.laneLedger.handles(event);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private disableLedger(reason: string) {
|
|
644
|
+
if (this.ledgerDisabled) return;
|
|
645
|
+
this.ledgerDisabled = true;
|
|
646
|
+
this.opts.log?.warn(
|
|
647
|
+
`dispatch ledger unavailable (${reason}) — falling back to legacy received/ack flow`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Buffer grouping key. Lane-ledger message events group by full lane
|
|
653
|
+
* identity (chat + thread) so a channel lane and a thread lane in the same
|
|
654
|
+
* chat dispatch as separate turns with separate claims; everything else
|
|
655
|
+
* keeps the historical chat-level grouping.
|
|
656
|
+
*/
|
|
657
|
+
private dispatchGroupKey(event: ParallEvent): string {
|
|
658
|
+
if (this.usesLaneLedger(event)) {
|
|
659
|
+
// MUST be the lane identity itself (lane-key SSOT): the mid-turn
|
|
660
|
+
// injection gate compares this against mainCurrentGroupKey, and a
|
|
661
|
+
// grouping key that drifted from lane identity would fold two lanes
|
|
662
|
+
// into one turn.
|
|
663
|
+
return this.laneLedger!.laneKeyFor(event);
|
|
664
|
+
}
|
|
665
|
+
return event.targetId;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Lane-flow protocols live in gateway-lane-flow.ts; these thin delegates
|
|
669
|
+
// keep call sites and tests on the class surface.
|
|
670
|
+
private laneFlowHost(): LaneFlowHost {
|
|
671
|
+
return this as unknown as LaneFlowHost;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private dispatchLaneGroup(opts: {
|
|
675
|
+
events: ParallEvent[];
|
|
676
|
+
sessionKey: string;
|
|
677
|
+
body: string;
|
|
678
|
+
earlier: ParallEvent[];
|
|
679
|
+
captureText?: string[];
|
|
680
|
+
hasMoreLocal: () => boolean;
|
|
681
|
+
}): Promise<'dispatched' | 'foreign' | 'shutdown'> {
|
|
682
|
+
return dispatchLaneGroup(this.laneFlowHost(), opts);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private consumeTypedDispatch(
|
|
686
|
+
ref: { dispatchEventId?: string; sourceType?: string; sourceId?: string },
|
|
687
|
+
run: (dispatchEventId?: string) => Promise<boolean>,
|
|
688
|
+
ack: (dispatchEventId?: string) => void,
|
|
689
|
+
): Promise<void> {
|
|
690
|
+
return consumeTypedDispatch(this.laneFlowHost(), ref, run, ack);
|
|
691
|
+
}
|
|
692
|
+
|
|
539
693
|
private buildDispatchContext(event: ParallEvent, sessionKey: string): DispatchContext {
|
|
540
694
|
const binding = this.sessionBindings.get(sessionKey);
|
|
541
695
|
return {
|
|
@@ -552,6 +706,7 @@ export class ParallAgentGateway {
|
|
|
552
706
|
noReply: event.noReply ?? false,
|
|
553
707
|
contextFilePath: this.opts.contextFilePathForSession?.(sessionKey),
|
|
554
708
|
stepIdFilePath: this.opts.stepIdFilePathForSession?.(sessionKey),
|
|
709
|
+
contextDirPath: this.opts.dispatchContextDir,
|
|
555
710
|
client: this.opts.client,
|
|
556
711
|
log: this.opts.log,
|
|
557
712
|
};
|
|
@@ -588,9 +743,11 @@ export class ParallAgentGateway {
|
|
|
588
743
|
? 'schedule_fire'
|
|
589
744
|
: event.type === 'external_trigger'
|
|
590
745
|
? 'external_trigger'
|
|
591
|
-
: event.type === '
|
|
592
|
-
? '
|
|
593
|
-
: '
|
|
746
|
+
: event.type === 'channel_message'
|
|
747
|
+
? 'channel_message'
|
|
748
|
+
: event.type === 'approval'
|
|
749
|
+
? 'approval_decided'
|
|
750
|
+
: 'mention',
|
|
594
751
|
trigger_ref:
|
|
595
752
|
event.type === 'task'
|
|
596
753
|
? { task_id: event.targetId }
|
|
@@ -607,9 +764,16 @@ export class ParallAgentGateway {
|
|
|
607
764
|
connection_id: event.externalConnectionId,
|
|
608
765
|
ingress_event_id: event.externalIngressEventId,
|
|
609
766
|
}
|
|
610
|
-
: event.type === '
|
|
611
|
-
? {
|
|
612
|
-
|
|
767
|
+
: event.type === 'channel_message'
|
|
768
|
+
? {
|
|
769
|
+
conversation_id: event.targetId,
|
|
770
|
+
channel_message_id: event.messageId,
|
|
771
|
+
provider: event.channelProvider,
|
|
772
|
+
external_conversation_id: event.channelExternalConversationId,
|
|
773
|
+
}
|
|
774
|
+
: event.type === 'approval'
|
|
775
|
+
? { approval_id: event.messageId }
|
|
776
|
+
: { message_id: event.messageId },
|
|
613
777
|
sender_id: event.senderId,
|
|
614
778
|
sender_name: event.senderName,
|
|
615
779
|
summary: event.body.substring(0, 200),
|
|
@@ -629,6 +793,7 @@ export class ParallAgentGateway {
|
|
|
629
793
|
runtimeEvent: RuntimeEvent,
|
|
630
794
|
stepIdFilePath?: string,
|
|
631
795
|
contextFilePath?: string,
|
|
796
|
+
laneContextFilePath?: string,
|
|
632
797
|
) {
|
|
633
798
|
const target = resolveStepTarget(event);
|
|
634
799
|
try {
|
|
@@ -692,6 +857,9 @@ export class ParallAgentGateway {
|
|
|
692
857
|
} else if (stepIdFilePath) {
|
|
693
858
|
this.writeStepIdFile(stepIdFilePath, step.id);
|
|
694
859
|
}
|
|
860
|
+
if (laneContextFilePath) {
|
|
861
|
+
this.updateContextFileStepId(laneContextFilePath, step.id);
|
|
862
|
+
}
|
|
695
863
|
break;
|
|
696
864
|
}
|
|
697
865
|
|
|
@@ -720,6 +888,9 @@ export class ParallAgentGateway {
|
|
|
720
888
|
} else if (stepIdFilePath) {
|
|
721
889
|
this.clearStepIdFile(stepIdFilePath);
|
|
722
890
|
}
|
|
891
|
+
if (laneContextFilePath) {
|
|
892
|
+
this.updateContextFileStepId(laneContextFilePath, null);
|
|
893
|
+
}
|
|
723
894
|
break;
|
|
724
895
|
|
|
725
896
|
case 'error':
|
|
@@ -803,6 +974,7 @@ export class ParallAgentGateway {
|
|
|
803
974
|
sessionKey: string,
|
|
804
975
|
runtimeEvent: Extract<RuntimeEvent, { type: 'runtime_session' }>,
|
|
805
976
|
contextFilePath?: string,
|
|
977
|
+
laneContextFilePath?: string,
|
|
806
978
|
): Promise<AgentSessionBinding> {
|
|
807
979
|
const runtimeLaneKey = runtimeEvent.runtimeLaneKey || sessionKey;
|
|
808
980
|
const existing = this.sessionBindings.get(sessionKey);
|
|
@@ -864,6 +1036,9 @@ export class ParallAgentGateway {
|
|
|
864
1036
|
if (contextFilePath) {
|
|
865
1037
|
this.updateContextFileSessionId(contextFilePath, session.id);
|
|
866
1038
|
}
|
|
1039
|
+
if (laneContextFilePath) {
|
|
1040
|
+
this.updateContextFileSessionId(laneContextFilePath, session.id);
|
|
1041
|
+
}
|
|
867
1042
|
await this.opts.onSessionBinding?.(binding);
|
|
868
1043
|
return binding;
|
|
869
1044
|
}
|
|
@@ -907,14 +1082,33 @@ export class ParallAgentGateway {
|
|
|
907
1082
|
const contextFilePath = dispatchContext.contextFilePath;
|
|
908
1083
|
const stepIdFilePath = dispatchContext.stepIdFilePath;
|
|
909
1084
|
|
|
1085
|
+
// Per-lane context (PRLL_CONTEXT_DIR contract): additive dispatch/lane
|
|
1086
|
+
// fields ride along in both files; the per-session file stays as the
|
|
1087
|
+
// PRLL_CONTEXT_FILE compat read path.
|
|
1088
|
+
const activeLane = this.ledgerDisabled ? undefined : this.laneLedger?.getForEvent(event);
|
|
1089
|
+
const laneContextFilePath = activeLane
|
|
1090
|
+
? this.laneLedger?.laneContextPath(activeLane)
|
|
1091
|
+
: undefined;
|
|
1092
|
+
const contextBody = {
|
|
1093
|
+
session_id: dispatchContext.sessionId ?? null,
|
|
1094
|
+
chat_id: dispatchContext.chatId ?? null,
|
|
1095
|
+
trigger_message_id: dispatchContext.triggerMessageId ?? null,
|
|
1096
|
+
no_reply: dispatchContext.noReply,
|
|
1097
|
+
step_id: null,
|
|
1098
|
+
dispatch_event_id:
|
|
1099
|
+
activeLane?.typedDispatchEventId ?? activeLane?.folded.get(event.messageId) ?? null,
|
|
1100
|
+
lane: activeLane?.lane ?? null,
|
|
1101
|
+
target_uri: activeLane?.targetUri ?? null,
|
|
1102
|
+
thread_root_id: activeLane?.threadRootId ?? null,
|
|
1103
|
+
// Typed binding hint for the CLI: which task this dispatch is about
|
|
1104
|
+
// (parall task update attaches the typed effect only on a match).
|
|
1105
|
+
task_id: event.type === 'task' ? event.targetId : null,
|
|
1106
|
+
};
|
|
910
1107
|
if (contextFilePath) {
|
|
911
|
-
this.writeContextFile(contextFilePath,
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
no_reply: dispatchContext.noReply,
|
|
916
|
-
step_id: null,
|
|
917
|
-
});
|
|
1108
|
+
this.writeContextFile(contextFilePath, contextBody);
|
|
1109
|
+
}
|
|
1110
|
+
if (laneContextFilePath) {
|
|
1111
|
+
this.writeContextFile(laneContextFilePath, contextBody);
|
|
918
1112
|
}
|
|
919
1113
|
|
|
920
1114
|
// sync: no await between the shuttingDown check above and this increment
|
|
@@ -951,7 +1145,32 @@ export class ParallAgentGateway {
|
|
|
951
1145
|
context: dispatchContext,
|
|
952
1146
|
})) {
|
|
953
1147
|
if (runtimeEvent.type === 'runtime_session') {
|
|
954
|
-
|
|
1148
|
+
const priorAgentSessionId = binding?.agentSessionId;
|
|
1149
|
+
binding = await this.bindRuntimeSession(
|
|
1150
|
+
sessionKey,
|
|
1151
|
+
runtimeEvent,
|
|
1152
|
+
contextFilePath,
|
|
1153
|
+
laneContextFilePath,
|
|
1154
|
+
);
|
|
1155
|
+
if (
|
|
1156
|
+
event.targetType === 'channel_conversation' &&
|
|
1157
|
+
binding.agentSessionId !== priorAgentSessionId
|
|
1158
|
+
) {
|
|
1159
|
+
// Record the durable chv_ ↔ ase_ mapping (ops drill-down from a
|
|
1160
|
+
// conversation into its session). Best-effort bookkeeping —
|
|
1161
|
+
// never fail the dispatch over it.
|
|
1162
|
+
try {
|
|
1163
|
+
await this.opts.client.setChannelConversationSession(
|
|
1164
|
+
this.opts.config.org_id,
|
|
1165
|
+
event.targetId,
|
|
1166
|
+
binding.agentSessionId,
|
|
1167
|
+
);
|
|
1168
|
+
} catch (err) {
|
|
1169
|
+
this.opts.log?.warn(
|
|
1170
|
+
`failed to record session mapping for channel conversation ${event.targetId}: ${String(err)}`,
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
955
1174
|
if (!inputStepsCreated) {
|
|
956
1175
|
// Persist input steps for "earlier events" (batched events that arrived
|
|
957
1176
|
// while a dispatch was in flight) inside the in-flight window so a
|
|
@@ -988,6 +1207,12 @@ export class ParallAgentGateway {
|
|
|
988
1207
|
await this.createInputStep(binding.agentSessionId, event);
|
|
989
1208
|
inputStepsCreated = true;
|
|
990
1209
|
}
|
|
1210
|
+
// Long-turn keepalive: any runtime activity renews the lane lease
|
|
1211
|
+
// (throttled in the ledger) so a legitimately long turn is not
|
|
1212
|
+
// dethroned at TTL.
|
|
1213
|
+
if (activeLane && !this.ledgerDisabled) {
|
|
1214
|
+
this.laneLedger?.maybeRenew(activeLane);
|
|
1215
|
+
}
|
|
991
1216
|
if (captureText && runtimeEvent.type === 'text' && runtimeEvent.text) {
|
|
992
1217
|
captureText.push(runtimeEvent.text);
|
|
993
1218
|
}
|
|
@@ -1013,6 +1238,7 @@ export class ParallAgentGateway {
|
|
|
1013
1238
|
runtimeEvent,
|
|
1014
1239
|
stepIdFilePath,
|
|
1015
1240
|
contextFilePath,
|
|
1241
|
+
laneContextFilePath,
|
|
1016
1242
|
);
|
|
1017
1243
|
}
|
|
1018
1244
|
if (!binding) {
|
|
@@ -1041,6 +1267,7 @@ export class ParallAgentGateway {
|
|
|
1041
1267
|
},
|
|
1042
1268
|
stepIdFilePath,
|
|
1043
1269
|
contextFilePath,
|
|
1270
|
+
laneContextFilePath,
|
|
1044
1271
|
);
|
|
1045
1272
|
} catch (stepErr) {
|
|
1046
1273
|
if (this.isSessionNotLiveError(stepErr)) staleDetected = true;
|
|
@@ -1105,6 +1332,9 @@ export class ParallAgentGateway {
|
|
|
1105
1332
|
} else if (stepIdFilePath) {
|
|
1106
1333
|
this.clearStepIdFile(stepIdFilePath);
|
|
1107
1334
|
}
|
|
1335
|
+
if (laneContextFilePath) {
|
|
1336
|
+
this.updateContextFileStepId(laneContextFilePath, null);
|
|
1337
|
+
}
|
|
1108
1338
|
this.inFlightDispatches--;
|
|
1109
1339
|
if (this.inFlightDispatches === 0 && this.drainResolvers.length > 0) {
|
|
1110
1340
|
const resolvers = this.drainResolvers.splice(0);
|
|
@@ -1174,19 +1404,47 @@ export class ParallAgentGateway {
|
|
|
1174
1404
|
for (const item of fork.queue.splice(0)) item.resolve(false);
|
|
1175
1405
|
break;
|
|
1176
1406
|
}
|
|
1177
|
-
|
|
1407
|
+
// Lane-ledger message events batch per lane identity (chat + thread)
|
|
1408
|
+
// so a fork's claim/steer/complete always addresses one lane.
|
|
1409
|
+
let items: ForkQueueItem[];
|
|
1410
|
+
const head = fork.queue[0];
|
|
1411
|
+
if (head && this.usesLaneLedger(head.event)) {
|
|
1412
|
+
const headKey = this.dispatchGroupKey(head.event);
|
|
1413
|
+
const splitAt = fork.queue.findIndex((it) => this.dispatchGroupKey(it.event) !== headKey);
|
|
1414
|
+
items = splitAt === -1 ? fork.queue.splice(0) : fork.queue.splice(0, splitAt);
|
|
1415
|
+
} else {
|
|
1416
|
+
items = fork.queue.splice(0);
|
|
1417
|
+
}
|
|
1178
1418
|
const events = items.map((item) => item.event);
|
|
1179
1419
|
const last = events[events.length - 1];
|
|
1180
1420
|
const earlier = events.slice(0, -1);
|
|
1181
1421
|
try {
|
|
1182
1422
|
const batchText: string[] = [];
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1423
|
+
let dispatched: boolean;
|
|
1424
|
+
if (this.usesLaneLedger(last)) {
|
|
1425
|
+
const outcome = await this.dispatchLaneGroup({
|
|
1426
|
+
events,
|
|
1427
|
+
sessionKey: fork.fork.sessionKey,
|
|
1428
|
+
body: buildForkScopePrefix(last) + buildEventBody(last),
|
|
1429
|
+
earlier,
|
|
1430
|
+
captureText: batchText,
|
|
1431
|
+
hasMoreLocal: () => fork.queue.length > 0,
|
|
1432
|
+
});
|
|
1433
|
+
if (outcome === 'foreign') {
|
|
1434
|
+
// Another pod owns the lane — the events stay pending server-side.
|
|
1435
|
+
for (const item of items) item.resolve(false);
|
|
1436
|
+
break;
|
|
1437
|
+
}
|
|
1438
|
+
dispatched = outcome === 'dispatched';
|
|
1439
|
+
} else {
|
|
1440
|
+
dispatched = await this.runDispatch(
|
|
1441
|
+
last,
|
|
1442
|
+
fork.fork.sessionKey,
|
|
1443
|
+
buildForkScopePrefix(last) + buildEventBody(last),
|
|
1444
|
+
earlier,
|
|
1445
|
+
batchText,
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1190
1448
|
if (!dispatched) {
|
|
1191
1449
|
// Shutdown short-circuit — resolve un-acked so the server requeues
|
|
1192
1450
|
// for the replacement pod and stop draining further items.
|
|
@@ -1306,9 +1564,12 @@ export class ParallAgentGateway {
|
|
|
1306
1564
|
break;
|
|
1307
1565
|
}
|
|
1308
1566
|
|
|
1309
|
-
const
|
|
1567
|
+
const groupKey = this.dispatchGroupKey(this.dispatchState.mainBuffer[0]);
|
|
1310
1568
|
const events: ParallEvent[] = [];
|
|
1311
|
-
while (
|
|
1569
|
+
while (
|
|
1570
|
+
this.dispatchState.mainBuffer[0] &&
|
|
1571
|
+
this.dispatchGroupKey(this.dispatchState.mainBuffer[0]) === groupKey
|
|
1572
|
+
) {
|
|
1312
1573
|
events.push(this.dispatchState.mainBuffer.shift()!);
|
|
1313
1574
|
}
|
|
1314
1575
|
|
|
@@ -1321,9 +1582,42 @@ export class ParallAgentGateway {
|
|
|
1321
1582
|
: this.dispatchState.pendingForkResults.splice(0);
|
|
1322
1583
|
const forkPrefix = buildForkResultPrefix(pendingFork);
|
|
1323
1584
|
this.dispatchState.mainCurrentTargetId = event.targetId;
|
|
1585
|
+
this.mainCurrentGroupKey = groupKey;
|
|
1324
1586
|
this.dispatchState.mainPreDispatchBranchPoint = this.opts.dispatchAdapter.getBranchPoint?.(
|
|
1325
1587
|
this.opts.runtimeKey,
|
|
1326
1588
|
);
|
|
1589
|
+
if (this.usesLaneLedger(event)) {
|
|
1590
|
+
let outcome: 'dispatched' | 'foreign' | 'shutdown';
|
|
1591
|
+
try {
|
|
1592
|
+
outcome = await this.dispatchLaneGroup({
|
|
1593
|
+
events,
|
|
1594
|
+
sessionKey: this.opts.runtimeKey,
|
|
1595
|
+
body: forkPrefix + buildEventBody(event),
|
|
1596
|
+
earlier,
|
|
1597
|
+
hasMoreLocal: () =>
|
|
1598
|
+
this.dispatchState.mainBuffer.some((e) => this.dispatchGroupKey(e) === groupKey),
|
|
1599
|
+
});
|
|
1600
|
+
} catch (err) {
|
|
1601
|
+
// The lane was released inside dispatchLaneGroup — members are
|
|
1602
|
+
// pending again server-side; drop them locally and move on.
|
|
1603
|
+
this.opts.log?.error(`lane dispatch failed for ${event.messageId}: ${String(err)}`);
|
|
1604
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1605
|
+
for (const ev of events) this.dispatchedMessages.delete(ev.messageId);
|
|
1606
|
+
continue;
|
|
1607
|
+
}
|
|
1608
|
+
if (outcome === 'shutdown') {
|
|
1609
|
+
this.dispatchState.mainBuffer.unshift(...events);
|
|
1610
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1611
|
+
break;
|
|
1612
|
+
}
|
|
1613
|
+
if (outcome === 'foreign') {
|
|
1614
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
// Dispatched — resolution happened server-side (reply cover or
|
|
1618
|
+
// no_action sweep); no legacy acks.
|
|
1619
|
+
continue;
|
|
1620
|
+
}
|
|
1327
1621
|
try {
|
|
1328
1622
|
await this.emitDispatchReceived(event);
|
|
1329
1623
|
} catch (err) {
|
|
@@ -1365,6 +1659,7 @@ export class ParallAgentGateway {
|
|
|
1365
1659
|
this.draining = false;
|
|
1366
1660
|
this.dispatchState.mainDispatching = false;
|
|
1367
1661
|
this.dispatchState.mainCurrentTargetId = undefined;
|
|
1662
|
+
this.mainCurrentGroupKey = undefined;
|
|
1368
1663
|
this.dispatchState.mainPreDispatchBranchPoint = undefined;
|
|
1369
1664
|
if (!this.shuttingDown && this.dispatchState.mainBuffer.length > 0) {
|
|
1370
1665
|
// Opportunistic re-drain — best-effort, not a recovery deadline, so it
|
|
@@ -1397,18 +1692,50 @@ export class ParallAgentGateway {
|
|
|
1397
1692
|
const forkPrefix = buildForkResultPrefix(pendingFork);
|
|
1398
1693
|
this.dispatchState.mainDispatching = true;
|
|
1399
1694
|
this.dispatchState.mainCurrentTargetId = event.targetId;
|
|
1695
|
+
this.mainCurrentGroupKey = this.dispatchGroupKey(event);
|
|
1400
1696
|
// Snapshot the on-disk branch point BEFORE runDispatch starts writing
|
|
1401
1697
|
// to the session file. Fork sessions created while main is in-flight
|
|
1402
1698
|
// use this to branch from the clean pre-dispatch state.
|
|
1403
1699
|
this.dispatchState.mainPreDispatchBranchPoint = this.opts.dispatchAdapter.getBranchPoint?.(
|
|
1404
1700
|
this.opts.runtimeKey,
|
|
1405
1701
|
);
|
|
1702
|
+
if (this.usesLaneLedger(event)) {
|
|
1703
|
+
// Ledger flow: claim replaces mark-received; complete/reply replace
|
|
1704
|
+
// acks. A foreign incumbent leaves the event pending for re-drive.
|
|
1705
|
+
let outcome: 'dispatched' | 'foreign' | 'shutdown' = 'shutdown';
|
|
1706
|
+
try {
|
|
1707
|
+
try {
|
|
1708
|
+
outcome = await this.dispatchLaneGroup({
|
|
1709
|
+
events: [event],
|
|
1710
|
+
sessionKey: this.opts.runtimeKey,
|
|
1711
|
+
body: forkPrefix + buildEventBody(event),
|
|
1712
|
+
earlier: [],
|
|
1713
|
+
hasMoreLocal: () =>
|
|
1714
|
+
this.dispatchState.mainBuffer.some(
|
|
1715
|
+
(e) => this.dispatchGroupKey(e) === this.dispatchGroupKey(event),
|
|
1716
|
+
),
|
|
1717
|
+
});
|
|
1718
|
+
} catch (err) {
|
|
1719
|
+
// Same failure contract as the buffered-group path: accumulated
|
|
1720
|
+
// fork results must survive a failed turn for later replay.
|
|
1721
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1722
|
+
throw err;
|
|
1723
|
+
}
|
|
1724
|
+
if (outcome !== 'dispatched') {
|
|
1725
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1726
|
+
}
|
|
1727
|
+
} finally {
|
|
1728
|
+
await this.drainMainBuffer();
|
|
1729
|
+
}
|
|
1730
|
+
return outcome === 'dispatched';
|
|
1731
|
+
}
|
|
1406
1732
|
try {
|
|
1407
1733
|
await this.emitDispatchReceived(event);
|
|
1408
1734
|
} catch (err) {
|
|
1409
1735
|
this.opts.log?.warn?.(`mark-received failed, leaving unacked for retry: ${String(err)}`);
|
|
1410
1736
|
this.dispatchState.mainDispatching = false;
|
|
1411
1737
|
this.dispatchState.mainCurrentTargetId = undefined;
|
|
1738
|
+
this.mainCurrentGroupKey = undefined;
|
|
1412
1739
|
this.dispatchState.mainPreDispatchBranchPoint = undefined;
|
|
1413
1740
|
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1414
1741
|
return false;
|
|
@@ -1439,7 +1766,26 @@ export class ParallAgentGateway {
|
|
|
1439
1766
|
// arrival order is preserved and the event cannot be orphaned in a
|
|
1440
1767
|
// gap between the steer await and the push.
|
|
1441
1768
|
this.dispatchState.mainBuffer.push(event);
|
|
1442
|
-
if (
|
|
1769
|
+
if (this.usesLaneLedger(event)) {
|
|
1770
|
+
// Ledger flow: fold into the live lane server-side FIRST, then
|
|
1771
|
+
// inject. An un-folded injection is forbidden (the pending WorkItem
|
|
1772
|
+
// would re-drive after complete and be handled twice); a failed
|
|
1773
|
+
// fold leaves the event buffered — the drain claims it as its own
|
|
1774
|
+
// turn. Injection requires an exact lane match (same chat AND same
|
|
1775
|
+
// thread) — a thread message never rides a channel turn.
|
|
1776
|
+
if (
|
|
1777
|
+
this.mainCurrentGroupKey === this.dispatchGroupKey(event) &&
|
|
1778
|
+
(await this.laneLedger?.steerLive(event)) &&
|
|
1779
|
+
(await this.opts.dispatchAdapter.enqueueDuringDispatch?.(
|
|
1780
|
+
this.opts.runtimeKey,
|
|
1781
|
+
buildEventBody(event),
|
|
1782
|
+
))
|
|
1783
|
+
) {
|
|
1784
|
+
this.opts.log?.info(
|
|
1785
|
+
`steer folded+injected for ${event.messageId} (will drain for bookkeeping)`,
|
|
1786
|
+
);
|
|
1787
|
+
}
|
|
1788
|
+
} else if (
|
|
1443
1789
|
this.dispatchState.mainCurrentTargetId === event.targetId &&
|
|
1444
1790
|
(await this.opts.dispatchAdapter.enqueueDuringDispatch?.(
|
|
1445
1791
|
this.opts.runtimeKey,
|
|
@@ -1649,9 +1995,13 @@ export class ParallAgentGateway {
|
|
|
1649
1995
|
try {
|
|
1650
1996
|
const dispatched = await this.handleInboundEvent(event);
|
|
1651
1997
|
if (dispatched) {
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1998
|
+
// Ledger events resolve server-side (reply cover / no_action sweep);
|
|
1999
|
+
// the legacy by-source ack is only for non-ledger runtimes.
|
|
2000
|
+
if (!this.usesLaneLedger(event)) {
|
|
2001
|
+
this.opts.client
|
|
2002
|
+
.ackDispatch(this.opts.config.org_id, { source_type: 'message', source_id: data.id })
|
|
2003
|
+
.catch(() => {});
|
|
2004
|
+
}
|
|
1655
2005
|
} else {
|
|
1656
2006
|
this.dispatchedMessages.delete(data.id);
|
|
1657
2007
|
}
|
|
@@ -1661,7 +2011,30 @@ export class ParallAgentGateway {
|
|
|
1661
2011
|
}
|
|
1662
2012
|
}
|
|
1663
2013
|
|
|
1664
|
-
|
|
2014
|
+
// Ledger re-drive consumption: dispatch.new message hints re-enter the
|
|
2015
|
+
// shared WorkItem consumption path (same protocol as catch-up).
|
|
2016
|
+
private async handleMessageRedrive(item: DispatchNewData): Promise<void> {
|
|
2017
|
+
if (!item.chat_id || !item.source_id) return;
|
|
2018
|
+
await this.consumeMessageWorkItem({
|
|
2019
|
+
id: item.id,
|
|
2020
|
+
source_id: item.source_id,
|
|
2021
|
+
chat_id: item.chat_id,
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
private consumeMessageWorkItem(item: {
|
|
2026
|
+
id: string;
|
|
2027
|
+
source_id: string;
|
|
2028
|
+
chat_id: string;
|
|
2029
|
+
}): Promise<void> {
|
|
2030
|
+
return consumeMessageWorkItem(this.laneFlowHost(), item);
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
private async handleTaskAssignment(
|
|
2034
|
+
task: Task,
|
|
2035
|
+
ackSourceId?: string,
|
|
2036
|
+
dispatchEventId?: string,
|
|
2037
|
+
): Promise<boolean> {
|
|
1665
2038
|
if (this.shuttingDown) return false; // drain window — let server requeue via catch-up
|
|
1666
2039
|
const dedupeKey = `${task.id}:${task.updated_at}`;
|
|
1667
2040
|
if (this.dispatchedTasks.has(dedupeKey)) {
|
|
@@ -1689,6 +2062,7 @@ export class ParallAgentGateway {
|
|
|
1689
2062
|
sentAt: task.updated_at ?? task.created_at,
|
|
1690
2063
|
ackSourceType: 'task_activity',
|
|
1691
2064
|
ackSourceId,
|
|
2065
|
+
dispatchEventId,
|
|
1692
2066
|
};
|
|
1693
2067
|
|
|
1694
2068
|
const dispatched = await this.handleInboundEvent(event);
|
|
@@ -1701,7 +2075,7 @@ export class ParallAgentGateway {
|
|
|
1701
2075
|
private async handleTaskDispatch(
|
|
1702
2076
|
taskId: string,
|
|
1703
2077
|
ackSourceId?: string,
|
|
1704
|
-
opts: { allowCreator?: boolean } = {},
|
|
2078
|
+
opts: { allowCreator?: boolean; dispatchEventId?: string } = {},
|
|
1705
2079
|
): Promise<boolean> {
|
|
1706
2080
|
let task: Awaited<ReturnType<typeof this.opts.client.getTask>> | null = null;
|
|
1707
2081
|
try {
|
|
@@ -1719,7 +2093,7 @@ export class ParallAgentGateway {
|
|
|
1719
2093
|
);
|
|
1720
2094
|
return true;
|
|
1721
2095
|
}
|
|
1722
|
-
return this.handleTaskAssignment(task, ackSourceId);
|
|
2096
|
+
return this.handleTaskAssignment(task, ackSourceId, opts.dispatchEventId);
|
|
1723
2097
|
}
|
|
1724
2098
|
|
|
1725
2099
|
private async handleTaskComment(
|
|
@@ -1976,6 +2350,98 @@ export class ParallAgentGateway {
|
|
|
1976
2350
|
return this.handleExternalTriggerRun(run);
|
|
1977
2351
|
}
|
|
1978
2352
|
|
|
2353
|
+
// fetchAndHandleChannelMessage resolves a channel_message dispatch to its
|
|
2354
|
+
// durable ChannelMessage + conversation and hands it to the inbound
|
|
2355
|
+
// pipeline. targetId = the ChannelConversation id, so per-conversation
|
|
2356
|
+
// multi-turn continuity rides the same per-target session mechanics as
|
|
2357
|
+
// chats. Design: docs/engineering-design/external-im-channel-design.md.
|
|
2358
|
+
private async fetchAndHandleChannelMessage(messageId: string): Promise<boolean> {
|
|
2359
|
+
if (this.shuttingDown) return false;
|
|
2360
|
+
// Capped dedupe (the chat-message path, not the unbounded task set): a busy
|
|
2361
|
+
// external IM conversation would otherwise retain one key per message ever
|
|
2362
|
+
// handled on a long-lived agent. On a RETRYABLE failure the claim is
|
|
2363
|
+
// released so a later dispatch.new / catch-up re-fetches (matching the
|
|
2364
|
+
// chat-message path); a 404/stale result keeps the claim and acks.
|
|
2365
|
+
const claimKey = `channel_message:${messageId}`;
|
|
2366
|
+
if (!this.tryClaimMessage(claimKey)) return false;
|
|
2367
|
+
|
|
2368
|
+
let msg: ChannelMessage | null = null;
|
|
2369
|
+
let conv: ChannelConversation | null = null;
|
|
2370
|
+
try {
|
|
2371
|
+
msg = await this.opts.client.getChannelMessage(this.opts.config.org_id, messageId);
|
|
2372
|
+
conv = await this.opts.client.getChannelConversation(
|
|
2373
|
+
this.opts.config.org_id,
|
|
2374
|
+
msg.conversation_id,
|
|
2375
|
+
);
|
|
2376
|
+
} catch (err: unknown) {
|
|
2377
|
+
const status = (err as { status?: number })?.status;
|
|
2378
|
+
if (status === 404) {
|
|
2379
|
+
this.opts.log?.warn(
|
|
2380
|
+
`channel message ${messageId} not accessible (404), acking stale dispatch`,
|
|
2381
|
+
);
|
|
2382
|
+
return true;
|
|
2383
|
+
}
|
|
2384
|
+
this.dispatchedMessages.delete(claimKey);
|
|
2385
|
+
this.opts.log?.warn(
|
|
2386
|
+
`channel message fetch failed for ${messageId}, leaving pending: ${String(err)}`,
|
|
2387
|
+
);
|
|
2388
|
+
return false;
|
|
2389
|
+
}
|
|
2390
|
+
if (!msg || !conv) {
|
|
2391
|
+
return true;
|
|
2392
|
+
}
|
|
2393
|
+
this.opts.log?.info(`channel message: ${msg.id} (conversation ${conv.id})`);
|
|
2394
|
+
|
|
2395
|
+
// Resolve the provider from the conversation's connection for prompt
|
|
2396
|
+
// labeling + the reply-clip hint. The connection id → provider mapping
|
|
2397
|
+
// is stable, so a tiny cache avoids one fetch per message.
|
|
2398
|
+
let provider = this.channelConnectionProviders.get(conv.connection_id);
|
|
2399
|
+
if (!provider) {
|
|
2400
|
+
try {
|
|
2401
|
+
const connection = await this.opts.client.getChannelConnection(
|
|
2402
|
+
this.opts.config.org_id,
|
|
2403
|
+
conv.connection_id,
|
|
2404
|
+
);
|
|
2405
|
+
provider = connection.provider;
|
|
2406
|
+
this.channelConnectionProviders.set(conv.connection_id, provider);
|
|
2407
|
+
} catch {
|
|
2408
|
+
provider = undefined; // label degrades; reply hint still names the clip generically
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
const event: ParallEvent = {
|
|
2413
|
+
type: 'channel_message',
|
|
2414
|
+
targetId: conv.id,
|
|
2415
|
+
targetName: conv.external_user_name || conv.external_conversation_id,
|
|
2416
|
+
targetType: 'channel_conversation',
|
|
2417
|
+
senderId: msg.external_user_id || 'external',
|
|
2418
|
+
senderName: msg.external_user_name || msg.external_user_id || 'external user',
|
|
2419
|
+
messageId: msg.id,
|
|
2420
|
+
body: msg.text,
|
|
2421
|
+
sentAt: msg.received_at,
|
|
2422
|
+
channelProvider: provider,
|
|
2423
|
+
channelConversationType: conv.conversation_type || undefined,
|
|
2424
|
+
channelExternalConversationId: conv.external_conversation_id,
|
|
2425
|
+
channelExternalMessageId: msg.external_message_id,
|
|
2426
|
+
ackSourceType: 'channel_message',
|
|
2427
|
+
ackSourceId: msg.id,
|
|
2428
|
+
};
|
|
2429
|
+
|
|
2430
|
+
// Release the claim if the event isn't actually dispatched (or throws) so
|
|
2431
|
+
// a retry can re-attempt — same contract as the chat-message path.
|
|
2432
|
+
let dispatched: boolean;
|
|
2433
|
+
try {
|
|
2434
|
+
dispatched = await this.handleInboundEvent(event);
|
|
2435
|
+
} catch (err) {
|
|
2436
|
+
this.dispatchedMessages.delete(claimKey);
|
|
2437
|
+
throw err;
|
|
2438
|
+
}
|
|
2439
|
+
if (!dispatched) {
|
|
2440
|
+
this.dispatchedMessages.delete(claimKey);
|
|
2441
|
+
}
|
|
2442
|
+
return dispatched;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
1979
2445
|
private async handleExternalTriggerRun(run: ExternalTriggerRun): Promise<boolean> {
|
|
1980
2446
|
if (this.shuttingDown) return false;
|
|
1981
2447
|
const dedupeKey = `external_trigger_run:${run.id}`;
|
|
@@ -2129,12 +2595,22 @@ export class ParallAgentGateway {
|
|
|
2129
2595
|
|
|
2130
2596
|
processed++;
|
|
2131
2597
|
try {
|
|
2132
|
-
|
|
2598
|
+
const ackItem = () => {
|
|
2599
|
+
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
|
|
2600
|
+
};
|
|
2133
2601
|
if (item.event_type === 'task_assign' && item.task_id) {
|
|
2134
2602
|
try {
|
|
2135
|
-
|
|
2136
|
-
item.
|
|
2137
|
-
|
|
2603
|
+
await this.consumeTypedDispatch(
|
|
2604
|
+
{ dispatchEventId: item.id },
|
|
2605
|
+
(dispatchEventId) =>
|
|
2606
|
+
this.handleTaskDispatch(
|
|
2607
|
+
item.task_id ?? '',
|
|
2608
|
+
item.source_id ?? item.task_id ?? '',
|
|
2609
|
+
{
|
|
2610
|
+
dispatchEventId,
|
|
2611
|
+
},
|
|
2612
|
+
),
|
|
2613
|
+
ackItem,
|
|
2138
2614
|
);
|
|
2139
2615
|
} catch (err: unknown) {
|
|
2140
2616
|
this.opts.log?.warn(
|
|
@@ -2144,10 +2620,18 @@ export class ParallAgentGateway {
|
|
|
2144
2620
|
}
|
|
2145
2621
|
} else if (item.event_type === 'task_update' && item.task_id) {
|
|
2146
2622
|
try {
|
|
2147
|
-
|
|
2148
|
-
item.
|
|
2149
|
-
|
|
2150
|
-
|
|
2623
|
+
await this.consumeTypedDispatch(
|
|
2624
|
+
{ dispatchEventId: item.id },
|
|
2625
|
+
(dispatchEventId) =>
|
|
2626
|
+
this.handleTaskDispatch(
|
|
2627
|
+
item.task_id ?? '',
|
|
2628
|
+
item.source_id ?? item.task_id ?? '',
|
|
2629
|
+
{
|
|
2630
|
+
allowCreator: true,
|
|
2631
|
+
dispatchEventId,
|
|
2632
|
+
},
|
|
2633
|
+
),
|
|
2634
|
+
ackItem,
|
|
2151
2635
|
);
|
|
2152
2636
|
} catch (err: unknown) {
|
|
2153
2637
|
this.opts.log?.warn(
|
|
@@ -2156,70 +2640,58 @@ export class ParallAgentGateway {
|
|
|
2156
2640
|
continue;
|
|
2157
2641
|
}
|
|
2158
2642
|
} else if (item.event_type === 'task_comment' && item.source_id && item.task_id) {
|
|
2159
|
-
|
|
2160
|
-
item.
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2643
|
+
await this.consumeTypedDispatch(
|
|
2644
|
+
{ dispatchEventId: item.id },
|
|
2645
|
+
() =>
|
|
2646
|
+
this.handleTaskComment(
|
|
2647
|
+
item.source_id,
|
|
2648
|
+
item.task_id ?? '',
|
|
2649
|
+
item.actor_id,
|
|
2650
|
+
item.delivery_reason,
|
|
2651
|
+
),
|
|
2652
|
+
ackItem,
|
|
2164
2653
|
);
|
|
2165
2654
|
} else if (item.event_type === 'wiki_comment' && item.source_id) {
|
|
2166
|
-
|
|
2167
|
-
item.
|
|
2168
|
-
item.actor_id,
|
|
2169
|
-
|
|
2655
|
+
await this.consumeTypedDispatch(
|
|
2656
|
+
{ dispatchEventId: item.id },
|
|
2657
|
+
() => this.handleWikiComment(item.source_id, item.actor_id, item.delivery_reason),
|
|
2658
|
+
ackItem,
|
|
2170
2659
|
);
|
|
2171
2660
|
} else if (item.event_type === 'schedule.fire' && item.source_id) {
|
|
2172
|
-
|
|
2661
|
+
await this.consumeTypedDispatch(
|
|
2662
|
+
{ dispatchEventId: item.id },
|
|
2663
|
+
() => this.fetchAndHandleScheduleFire(item.source_id, item.actor_id),
|
|
2664
|
+
ackItem,
|
|
2665
|
+
);
|
|
2173
2666
|
} else if (item.event_type === 'external_trigger' && item.source_id) {
|
|
2174
|
-
|
|
2667
|
+
await this.consumeTypedDispatch(
|
|
2668
|
+
{ dispatchEventId: item.id },
|
|
2669
|
+
() => this.fetchAndHandleExternalTriggerRun(item.source_id),
|
|
2670
|
+
ackItem,
|
|
2671
|
+
);
|
|
2672
|
+
} else if (item.event_type === 'channel_message' && item.source_id) {
|
|
2673
|
+
await this.consumeTypedDispatch(
|
|
2674
|
+
{ dispatchEventId: item.id },
|
|
2675
|
+
() => this.fetchAndHandleChannelMessage(item.source_id),
|
|
2676
|
+
ackItem,
|
|
2677
|
+
);
|
|
2175
2678
|
} else if (item.event_type === 'approval_decided' && item.source_id) {
|
|
2176
|
-
|
|
2177
|
-
item.
|
|
2178
|
-
|
|
2179
|
-
|
|
2679
|
+
await this.consumeTypedDispatch(
|
|
2680
|
+
{ dispatchEventId: item.id },
|
|
2681
|
+
() =>
|
|
2682
|
+
this.fetchAndHandleApprovalDecided(
|
|
2683
|
+
item.source_id,
|
|
2684
|
+
item.actor_id,
|
|
2685
|
+
item.chat_id ?? null,
|
|
2686
|
+
),
|
|
2687
|
+
ackItem,
|
|
2180
2688
|
);
|
|
2181
2689
|
} else if (item.event_type === 'message' && item.source_id && item.chat_id) {
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
} catch (err: unknown) {
|
|
2188
|
-
const status = (err as { status?: number })?.status;
|
|
2189
|
-
if (status === 404) {
|
|
2190
|
-
msg = null;
|
|
2191
|
-
} else {
|
|
2192
|
-
msgFetchFailed = true;
|
|
2193
|
-
this.opts.log?.warn(
|
|
2194
|
-
`catch-up message fetch failed for ${item.source_id}, leaving pending: ${String(err)}`,
|
|
2195
|
-
);
|
|
2196
|
-
}
|
|
2197
|
-
}
|
|
2198
|
-
if (msgFetchFailed) {
|
|
2199
|
-
this.dispatchedMessages.delete(item.source_id);
|
|
2200
|
-
continue;
|
|
2201
|
-
}
|
|
2202
|
-
if (!msg || msg.sender_id === this.opts.agentUserId) {
|
|
2203
|
-
this.dispatchedMessages.delete(item.source_id);
|
|
2204
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
|
|
2205
|
-
continue;
|
|
2206
|
-
}
|
|
2207
|
-
|
|
2208
|
-
const decision = await this.buildMessageDispatchDecision(item.chat_id, msg);
|
|
2209
|
-
if (decision.action === 'retry') {
|
|
2210
|
-
this.dispatchedMessages.delete(item.source_id);
|
|
2211
|
-
continue;
|
|
2212
|
-
}
|
|
2213
|
-
if (decision.action === 'skip') {
|
|
2214
|
-
this.dispatchedMessages.delete(item.source_id);
|
|
2215
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
|
|
2216
|
-
continue;
|
|
2217
|
-
}
|
|
2218
|
-
|
|
2219
|
-
dispatched = await this.handleInboundEvent(decision.event);
|
|
2220
|
-
}
|
|
2221
|
-
if (dispatched) {
|
|
2222
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
|
|
2690
|
+
await this.consumeMessageWorkItem({
|
|
2691
|
+
id: item.id,
|
|
2692
|
+
source_id: item.source_id,
|
|
2693
|
+
chat_id: item.chat_id,
|
|
2694
|
+
});
|
|
2223
2695
|
}
|
|
2224
2696
|
} catch (err) {
|
|
2225
2697
|
this.opts.log?.warn(
|
|
@@ -2307,6 +2779,20 @@ export class ParallAgentGateway {
|
|
|
2307
2779
|
this.abortFork(targetId, 'ws reconnect');
|
|
2308
2780
|
}
|
|
2309
2781
|
}
|
|
2782
|
+
// Reconnect with nothing in flight: interrupted turns can't resume, so
|
|
2783
|
+
// hand their lane members back to the pending pool before catch-up
|
|
2784
|
+
// re-claims (an in-flight turn keeps its lanes — it is still the owner).
|
|
2785
|
+
if (this.laneLedger && this.inFlightDispatches === 0 && this.laneLedger.activeCount > 0) {
|
|
2786
|
+
log?.info(`releasing ${this.laneLedger.activeCount} stale lane(s) on reconnect`);
|
|
2787
|
+
await this.laneLedger.releaseAll();
|
|
2788
|
+
}
|
|
2789
|
+
// Re-probe the ledger each connection: a sticky downgrade from a
|
|
2790
|
+
// transient edge 404 during a rolling deploy must not outlive the
|
|
2791
|
+
// connection that observed it.
|
|
2792
|
+
if (this.laneLedger && this.ledgerDisabled) {
|
|
2793
|
+
log?.info('re-probing dispatch ledger after reconnect (was disabled)');
|
|
2794
|
+
this.ledgerDisabled = false;
|
|
2795
|
+
}
|
|
2310
2796
|
const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
|
|
2311
2797
|
try {
|
|
2312
2798
|
const count = await fetchAllChats(client, config.org_id, this.chatInfoMap);
|
|
@@ -2391,6 +2877,14 @@ export class ParallAgentGateway {
|
|
|
2391
2877
|
|
|
2392
2878
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
2393
2879
|
|
|
2880
|
+
// Completed turns already released their lanes; whatever is left belongs
|
|
2881
|
+
// to interrupted work — hand the members back so the replacement pod
|
|
2882
|
+
// re-claims immediately instead of waiting out the lease.
|
|
2883
|
+
if (this.laneLedger && this.laneLedger.activeCount > 0) {
|
|
2884
|
+
this.opts.log?.info(`releasing ${this.laneLedger.activeCount} lane(s) on shutdown`);
|
|
2885
|
+
await this.laneLedger.releaseAll();
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2394
2888
|
await this.opts.onBeforeDisconnect?.();
|
|
2395
2889
|
|
|
2396
2890
|
this.opts.ws.disconnect();
|