@parall/agent-core 1.37.0 → 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/dispatch-adapter.d.ts +6 -0
- package/dist/dispatch-adapter.d.ts.map +1 -1
- package/dist/gateway-base.d.ts +54 -0
- package/dist/gateway-base.d.ts.map +1 -1
- package/dist/gateway-base.js +331 -95
- 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/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/dispatch-adapter.ts +6 -0
- package/src/gateway-base.ts +493 -142
- 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/types.ts +2 -0
package/src/gateway-base.ts
CHANGED
|
@@ -36,6 +36,13 @@ import type {
|
|
|
36
36
|
GatewayLogger,
|
|
37
37
|
RuntimeEvent,
|
|
38
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';
|
|
39
46
|
import { routeTrigger } from './routing.js';
|
|
40
47
|
import {
|
|
41
48
|
clearDispatchMessageId,
|
|
@@ -89,7 +96,7 @@ type ActiveForkState = {
|
|
|
89
96
|
deadlineExceeded: boolean;
|
|
90
97
|
};
|
|
91
98
|
|
|
92
|
-
type DispatchableMessage = {
|
|
99
|
+
export type DispatchableMessage = {
|
|
93
100
|
id: string;
|
|
94
101
|
sender_id: string;
|
|
95
102
|
sender?: { display_name?: string | null };
|
|
@@ -101,7 +108,7 @@ type DispatchableMessage = {
|
|
|
101
108
|
created_at?: string;
|
|
102
109
|
};
|
|
103
110
|
|
|
104
|
-
type MessageDispatchDecision =
|
|
111
|
+
export type MessageDispatchDecision =
|
|
105
112
|
| { action: 'dispatch'; event: ParallEvent }
|
|
106
113
|
| { action: 'skip' }
|
|
107
114
|
| { action: 'retry' };
|
|
@@ -133,6 +140,13 @@ export type ParallGatewayOptions = {
|
|
|
133
140
|
contextFilePathForSession?: (sessionKey: string) => string | undefined;
|
|
134
141
|
/** @deprecated Use contextFilePathForSession. Kept for runtimes that haven't migrated. */
|
|
135
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;
|
|
136
150
|
onConfigUpdate?: (data: AgentConfigUpdateData) => Promise<void> | void;
|
|
137
151
|
onSessionReady?: (state: {
|
|
138
152
|
activeSessionId?: string;
|
|
@@ -303,6 +317,15 @@ export class ParallAgentGateway {
|
|
|
303
317
|
private drainResolvers: Array<() => void> = [];
|
|
304
318
|
private pendingRestartNotification: string | null = null;
|
|
305
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
|
+
|
|
306
329
|
private readonly DISPATCHED_MESSAGES_CAP = 5000;
|
|
307
330
|
// SHUTDOWN_DEADLINE_MS is read by waitForDrain via the configured value
|
|
308
331
|
// below — kept as instance state so per-runtime configs can override it
|
|
@@ -312,6 +335,14 @@ export class ParallAgentGateway {
|
|
|
312
335
|
private readonly DISPATCH_DEADLINE_MS: number;
|
|
313
336
|
|
|
314
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
|
+
}
|
|
315
346
|
this.SHUTDOWN_DEADLINE_MS = opts.shutdownDeadlineMs ?? 60_000;
|
|
316
347
|
this.FORK_DEADLINE_MS = opts.forkDeadlineMs ?? 2 * 60 * 60_000;
|
|
317
348
|
this.DISPATCH_DEADLINE_MS = opts.dispatchDeadlineMs ?? 20 * 60_000;
|
|
@@ -395,15 +426,30 @@ export class ParallAgentGateway {
|
|
|
395
426
|
if (data.assignee_id !== this.opts.agentUserId) return;
|
|
396
427
|
if (data.status !== 'todo' && data.status !== 'in_progress') return;
|
|
397
428
|
try {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
.
|
|
406
|
-
|
|
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
|
+
);
|
|
407
453
|
} catch (err) {
|
|
408
454
|
this.opts.log?.error(`task dispatch failed for ${data.id}: ${String(err)}`);
|
|
409
455
|
}
|
|
@@ -413,15 +459,19 @@ export class ParallAgentGateway {
|
|
|
413
459
|
if (data.event_type === 'task_comment') {
|
|
414
460
|
if (!data.source_id || !data.task_id) return;
|
|
415
461
|
try {
|
|
416
|
-
|
|
417
|
-
data.
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
+
},
|
|
421
474
|
);
|
|
422
|
-
if (dispatched) {
|
|
423
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
424
|
-
}
|
|
425
475
|
} catch (err) {
|
|
426
476
|
this.opts.log?.error(
|
|
427
477
|
`task comment dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
@@ -430,14 +480,13 @@ export class ParallAgentGateway {
|
|
|
430
480
|
} else if (data.event_type === 'wiki_comment') {
|
|
431
481
|
if (!data.source_id) return;
|
|
432
482
|
try {
|
|
433
|
-
|
|
434
|
-
data.
|
|
435
|
-
data.actor_id,
|
|
436
|
-
|
|
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
|
+
},
|
|
437
489
|
);
|
|
438
|
-
if (dispatched) {
|
|
439
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
440
|
-
}
|
|
441
490
|
} catch (err) {
|
|
442
491
|
this.opts.log?.error(
|
|
443
492
|
`wiki comment dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
@@ -446,24 +495,30 @@ export class ParallAgentGateway {
|
|
|
446
495
|
} else if (data.event_type === 'task_update') {
|
|
447
496
|
if (!data.task_id) return;
|
|
448
497
|
try {
|
|
449
|
-
|
|
450
|
-
data.
|
|
451
|
-
|
|
452
|
-
|
|
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
|
+
},
|
|
453
508
|
);
|
|
454
|
-
if (dispatched) {
|
|
455
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
456
|
-
}
|
|
457
509
|
} catch (err) {
|
|
458
510
|
this.opts.log?.error(`task update dispatch failed for ${data.task_id}: ${String(err)}`);
|
|
459
511
|
}
|
|
460
512
|
} else if (data.event_type === 'schedule.fire') {
|
|
461
513
|
if (!data.source_id) return;
|
|
462
514
|
try {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
this.
|
|
466
|
-
|
|
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
|
+
);
|
|
467
522
|
} catch (err) {
|
|
468
523
|
this.opts.log?.error(
|
|
469
524
|
`schedule fire dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
@@ -472,10 +527,13 @@ export class ParallAgentGateway {
|
|
|
472
527
|
} else if (data.event_type === 'external_trigger') {
|
|
473
528
|
if (!data.source_id) return;
|
|
474
529
|
try {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
this.
|
|
478
|
-
|
|
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
|
+
);
|
|
479
537
|
} catch (err) {
|
|
480
538
|
this.opts.log?.error(
|
|
481
539
|
`external trigger dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
@@ -484,10 +542,13 @@ export class ParallAgentGateway {
|
|
|
484
542
|
} else if (data.event_type === 'channel_message') {
|
|
485
543
|
if (!data.source_id) return;
|
|
486
544
|
try {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
this.
|
|
490
|
-
|
|
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
|
+
);
|
|
491
552
|
} catch (err) {
|
|
492
553
|
this.opts.log?.error(
|
|
493
554
|
`channel message dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
@@ -496,19 +557,37 @@ export class ParallAgentGateway {
|
|
|
496
557
|
} else if (data.event_type === 'approval_decided') {
|
|
497
558
|
if (!data.source_id) return;
|
|
498
559
|
try {
|
|
499
|
-
|
|
500
|
-
data.
|
|
501
|
-
|
|
502
|
-
|
|
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
|
+
},
|
|
503
571
|
);
|
|
504
|
-
if (dispatched) {
|
|
505
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
506
|
-
}
|
|
507
572
|
} catch (err) {
|
|
508
573
|
this.opts.log?.error(
|
|
509
574
|
`approval decided dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
510
575
|
);
|
|
511
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
|
+
}
|
|
512
591
|
} else if (data.event_type !== 'message' && data.event_type !== 'task_assign') {
|
|
513
592
|
// Truly unknown event_type — log so a newly-added dispatch type
|
|
514
593
|
// not yet wired here surfaces during runtime testing. "message"
|
|
@@ -556,6 +635,61 @@ export class ParallAgentGateway {
|
|
|
556
635
|
});
|
|
557
636
|
}
|
|
558
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
|
+
|
|
559
693
|
private buildDispatchContext(event: ParallEvent, sessionKey: string): DispatchContext {
|
|
560
694
|
const binding = this.sessionBindings.get(sessionKey);
|
|
561
695
|
return {
|
|
@@ -572,6 +706,7 @@ export class ParallAgentGateway {
|
|
|
572
706
|
noReply: event.noReply ?? false,
|
|
573
707
|
contextFilePath: this.opts.contextFilePathForSession?.(sessionKey),
|
|
574
708
|
stepIdFilePath: this.opts.stepIdFilePathForSession?.(sessionKey),
|
|
709
|
+
contextDirPath: this.opts.dispatchContextDir,
|
|
575
710
|
client: this.opts.client,
|
|
576
711
|
log: this.opts.log,
|
|
577
712
|
};
|
|
@@ -658,6 +793,7 @@ export class ParallAgentGateway {
|
|
|
658
793
|
runtimeEvent: RuntimeEvent,
|
|
659
794
|
stepIdFilePath?: string,
|
|
660
795
|
contextFilePath?: string,
|
|
796
|
+
laneContextFilePath?: string,
|
|
661
797
|
) {
|
|
662
798
|
const target = resolveStepTarget(event);
|
|
663
799
|
try {
|
|
@@ -721,6 +857,9 @@ export class ParallAgentGateway {
|
|
|
721
857
|
} else if (stepIdFilePath) {
|
|
722
858
|
this.writeStepIdFile(stepIdFilePath, step.id);
|
|
723
859
|
}
|
|
860
|
+
if (laneContextFilePath) {
|
|
861
|
+
this.updateContextFileStepId(laneContextFilePath, step.id);
|
|
862
|
+
}
|
|
724
863
|
break;
|
|
725
864
|
}
|
|
726
865
|
|
|
@@ -749,6 +888,9 @@ export class ParallAgentGateway {
|
|
|
749
888
|
} else if (stepIdFilePath) {
|
|
750
889
|
this.clearStepIdFile(stepIdFilePath);
|
|
751
890
|
}
|
|
891
|
+
if (laneContextFilePath) {
|
|
892
|
+
this.updateContextFileStepId(laneContextFilePath, null);
|
|
893
|
+
}
|
|
752
894
|
break;
|
|
753
895
|
|
|
754
896
|
case 'error':
|
|
@@ -832,6 +974,7 @@ export class ParallAgentGateway {
|
|
|
832
974
|
sessionKey: string,
|
|
833
975
|
runtimeEvent: Extract<RuntimeEvent, { type: 'runtime_session' }>,
|
|
834
976
|
contextFilePath?: string,
|
|
977
|
+
laneContextFilePath?: string,
|
|
835
978
|
): Promise<AgentSessionBinding> {
|
|
836
979
|
const runtimeLaneKey = runtimeEvent.runtimeLaneKey || sessionKey;
|
|
837
980
|
const existing = this.sessionBindings.get(sessionKey);
|
|
@@ -893,6 +1036,9 @@ export class ParallAgentGateway {
|
|
|
893
1036
|
if (contextFilePath) {
|
|
894
1037
|
this.updateContextFileSessionId(contextFilePath, session.id);
|
|
895
1038
|
}
|
|
1039
|
+
if (laneContextFilePath) {
|
|
1040
|
+
this.updateContextFileSessionId(laneContextFilePath, session.id);
|
|
1041
|
+
}
|
|
896
1042
|
await this.opts.onSessionBinding?.(binding);
|
|
897
1043
|
return binding;
|
|
898
1044
|
}
|
|
@@ -936,14 +1082,33 @@ export class ParallAgentGateway {
|
|
|
936
1082
|
const contextFilePath = dispatchContext.contextFilePath;
|
|
937
1083
|
const stepIdFilePath = dispatchContext.stepIdFilePath;
|
|
938
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
|
+
};
|
|
939
1107
|
if (contextFilePath) {
|
|
940
|
-
this.writeContextFile(contextFilePath,
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
no_reply: dispatchContext.noReply,
|
|
945
|
-
step_id: null,
|
|
946
|
-
});
|
|
1108
|
+
this.writeContextFile(contextFilePath, contextBody);
|
|
1109
|
+
}
|
|
1110
|
+
if (laneContextFilePath) {
|
|
1111
|
+
this.writeContextFile(laneContextFilePath, contextBody);
|
|
947
1112
|
}
|
|
948
1113
|
|
|
949
1114
|
// sync: no await between the shuttingDown check above and this increment
|
|
@@ -981,7 +1146,12 @@ export class ParallAgentGateway {
|
|
|
981
1146
|
})) {
|
|
982
1147
|
if (runtimeEvent.type === 'runtime_session') {
|
|
983
1148
|
const priorAgentSessionId = binding?.agentSessionId;
|
|
984
|
-
binding = await this.bindRuntimeSession(
|
|
1149
|
+
binding = await this.bindRuntimeSession(
|
|
1150
|
+
sessionKey,
|
|
1151
|
+
runtimeEvent,
|
|
1152
|
+
contextFilePath,
|
|
1153
|
+
laneContextFilePath,
|
|
1154
|
+
);
|
|
985
1155
|
if (
|
|
986
1156
|
event.targetType === 'channel_conversation' &&
|
|
987
1157
|
binding.agentSessionId !== priorAgentSessionId
|
|
@@ -1037,6 +1207,12 @@ export class ParallAgentGateway {
|
|
|
1037
1207
|
await this.createInputStep(binding.agentSessionId, event);
|
|
1038
1208
|
inputStepsCreated = true;
|
|
1039
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
|
+
}
|
|
1040
1216
|
if (captureText && runtimeEvent.type === 'text' && runtimeEvent.text) {
|
|
1041
1217
|
captureText.push(runtimeEvent.text);
|
|
1042
1218
|
}
|
|
@@ -1062,6 +1238,7 @@ export class ParallAgentGateway {
|
|
|
1062
1238
|
runtimeEvent,
|
|
1063
1239
|
stepIdFilePath,
|
|
1064
1240
|
contextFilePath,
|
|
1241
|
+
laneContextFilePath,
|
|
1065
1242
|
);
|
|
1066
1243
|
}
|
|
1067
1244
|
if (!binding) {
|
|
@@ -1090,6 +1267,7 @@ export class ParallAgentGateway {
|
|
|
1090
1267
|
},
|
|
1091
1268
|
stepIdFilePath,
|
|
1092
1269
|
contextFilePath,
|
|
1270
|
+
laneContextFilePath,
|
|
1093
1271
|
);
|
|
1094
1272
|
} catch (stepErr) {
|
|
1095
1273
|
if (this.isSessionNotLiveError(stepErr)) staleDetected = true;
|
|
@@ -1154,6 +1332,9 @@ export class ParallAgentGateway {
|
|
|
1154
1332
|
} else if (stepIdFilePath) {
|
|
1155
1333
|
this.clearStepIdFile(stepIdFilePath);
|
|
1156
1334
|
}
|
|
1335
|
+
if (laneContextFilePath) {
|
|
1336
|
+
this.updateContextFileStepId(laneContextFilePath, null);
|
|
1337
|
+
}
|
|
1157
1338
|
this.inFlightDispatches--;
|
|
1158
1339
|
if (this.inFlightDispatches === 0 && this.drainResolvers.length > 0) {
|
|
1159
1340
|
const resolvers = this.drainResolvers.splice(0);
|
|
@@ -1223,19 +1404,47 @@ export class ParallAgentGateway {
|
|
|
1223
1404
|
for (const item of fork.queue.splice(0)) item.resolve(false);
|
|
1224
1405
|
break;
|
|
1225
1406
|
}
|
|
1226
|
-
|
|
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
|
+
}
|
|
1227
1418
|
const events = items.map((item) => item.event);
|
|
1228
1419
|
const last = events[events.length - 1];
|
|
1229
1420
|
const earlier = events.slice(0, -1);
|
|
1230
1421
|
try {
|
|
1231
1422
|
const batchText: string[] = [];
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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
|
+
}
|
|
1239
1448
|
if (!dispatched) {
|
|
1240
1449
|
// Shutdown short-circuit — resolve un-acked so the server requeues
|
|
1241
1450
|
// for the replacement pod and stop draining further items.
|
|
@@ -1355,9 +1564,12 @@ export class ParallAgentGateway {
|
|
|
1355
1564
|
break;
|
|
1356
1565
|
}
|
|
1357
1566
|
|
|
1358
|
-
const
|
|
1567
|
+
const groupKey = this.dispatchGroupKey(this.dispatchState.mainBuffer[0]);
|
|
1359
1568
|
const events: ParallEvent[] = [];
|
|
1360
|
-
while (
|
|
1569
|
+
while (
|
|
1570
|
+
this.dispatchState.mainBuffer[0] &&
|
|
1571
|
+
this.dispatchGroupKey(this.dispatchState.mainBuffer[0]) === groupKey
|
|
1572
|
+
) {
|
|
1361
1573
|
events.push(this.dispatchState.mainBuffer.shift()!);
|
|
1362
1574
|
}
|
|
1363
1575
|
|
|
@@ -1370,9 +1582,42 @@ export class ParallAgentGateway {
|
|
|
1370
1582
|
: this.dispatchState.pendingForkResults.splice(0);
|
|
1371
1583
|
const forkPrefix = buildForkResultPrefix(pendingFork);
|
|
1372
1584
|
this.dispatchState.mainCurrentTargetId = event.targetId;
|
|
1585
|
+
this.mainCurrentGroupKey = groupKey;
|
|
1373
1586
|
this.dispatchState.mainPreDispatchBranchPoint = this.opts.dispatchAdapter.getBranchPoint?.(
|
|
1374
1587
|
this.opts.runtimeKey,
|
|
1375
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
|
+
}
|
|
1376
1621
|
try {
|
|
1377
1622
|
await this.emitDispatchReceived(event);
|
|
1378
1623
|
} catch (err) {
|
|
@@ -1414,6 +1659,7 @@ export class ParallAgentGateway {
|
|
|
1414
1659
|
this.draining = false;
|
|
1415
1660
|
this.dispatchState.mainDispatching = false;
|
|
1416
1661
|
this.dispatchState.mainCurrentTargetId = undefined;
|
|
1662
|
+
this.mainCurrentGroupKey = undefined;
|
|
1417
1663
|
this.dispatchState.mainPreDispatchBranchPoint = undefined;
|
|
1418
1664
|
if (!this.shuttingDown && this.dispatchState.mainBuffer.length > 0) {
|
|
1419
1665
|
// Opportunistic re-drain — best-effort, not a recovery deadline, so it
|
|
@@ -1446,18 +1692,50 @@ export class ParallAgentGateway {
|
|
|
1446
1692
|
const forkPrefix = buildForkResultPrefix(pendingFork);
|
|
1447
1693
|
this.dispatchState.mainDispatching = true;
|
|
1448
1694
|
this.dispatchState.mainCurrentTargetId = event.targetId;
|
|
1695
|
+
this.mainCurrentGroupKey = this.dispatchGroupKey(event);
|
|
1449
1696
|
// Snapshot the on-disk branch point BEFORE runDispatch starts writing
|
|
1450
1697
|
// to the session file. Fork sessions created while main is in-flight
|
|
1451
1698
|
// use this to branch from the clean pre-dispatch state.
|
|
1452
1699
|
this.dispatchState.mainPreDispatchBranchPoint = this.opts.dispatchAdapter.getBranchPoint?.(
|
|
1453
1700
|
this.opts.runtimeKey,
|
|
1454
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
|
+
}
|
|
1455
1732
|
try {
|
|
1456
1733
|
await this.emitDispatchReceived(event);
|
|
1457
1734
|
} catch (err) {
|
|
1458
1735
|
this.opts.log?.warn?.(`mark-received failed, leaving unacked for retry: ${String(err)}`);
|
|
1459
1736
|
this.dispatchState.mainDispatching = false;
|
|
1460
1737
|
this.dispatchState.mainCurrentTargetId = undefined;
|
|
1738
|
+
this.mainCurrentGroupKey = undefined;
|
|
1461
1739
|
this.dispatchState.mainPreDispatchBranchPoint = undefined;
|
|
1462
1740
|
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1463
1741
|
return false;
|
|
@@ -1488,7 +1766,26 @@ export class ParallAgentGateway {
|
|
|
1488
1766
|
// arrival order is preserved and the event cannot be orphaned in a
|
|
1489
1767
|
// gap between the steer await and the push.
|
|
1490
1768
|
this.dispatchState.mainBuffer.push(event);
|
|
1491
|
-
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 (
|
|
1492
1789
|
this.dispatchState.mainCurrentTargetId === event.targetId &&
|
|
1493
1790
|
(await this.opts.dispatchAdapter.enqueueDuringDispatch?.(
|
|
1494
1791
|
this.opts.runtimeKey,
|
|
@@ -1698,9 +1995,13 @@ export class ParallAgentGateway {
|
|
|
1698
1995
|
try {
|
|
1699
1996
|
const dispatched = await this.handleInboundEvent(event);
|
|
1700
1997
|
if (dispatched) {
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
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
|
+
}
|
|
1704
2005
|
} else {
|
|
1705
2006
|
this.dispatchedMessages.delete(data.id);
|
|
1706
2007
|
}
|
|
@@ -1710,7 +2011,30 @@ export class ParallAgentGateway {
|
|
|
1710
2011
|
}
|
|
1711
2012
|
}
|
|
1712
2013
|
|
|
1713
|
-
|
|
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> {
|
|
1714
2038
|
if (this.shuttingDown) return false; // drain window — let server requeue via catch-up
|
|
1715
2039
|
const dedupeKey = `${task.id}:${task.updated_at}`;
|
|
1716
2040
|
if (this.dispatchedTasks.has(dedupeKey)) {
|
|
@@ -1738,6 +2062,7 @@ export class ParallAgentGateway {
|
|
|
1738
2062
|
sentAt: task.updated_at ?? task.created_at,
|
|
1739
2063
|
ackSourceType: 'task_activity',
|
|
1740
2064
|
ackSourceId,
|
|
2065
|
+
dispatchEventId,
|
|
1741
2066
|
};
|
|
1742
2067
|
|
|
1743
2068
|
const dispatched = await this.handleInboundEvent(event);
|
|
@@ -1750,7 +2075,7 @@ export class ParallAgentGateway {
|
|
|
1750
2075
|
private async handleTaskDispatch(
|
|
1751
2076
|
taskId: string,
|
|
1752
2077
|
ackSourceId?: string,
|
|
1753
|
-
opts: { allowCreator?: boolean } = {},
|
|
2078
|
+
opts: { allowCreator?: boolean; dispatchEventId?: string } = {},
|
|
1754
2079
|
): Promise<boolean> {
|
|
1755
2080
|
let task: Awaited<ReturnType<typeof this.opts.client.getTask>> | null = null;
|
|
1756
2081
|
try {
|
|
@@ -1768,7 +2093,7 @@ export class ParallAgentGateway {
|
|
|
1768
2093
|
);
|
|
1769
2094
|
return true;
|
|
1770
2095
|
}
|
|
1771
|
-
return this.handleTaskAssignment(task, ackSourceId);
|
|
2096
|
+
return this.handleTaskAssignment(task, ackSourceId, opts.dispatchEventId);
|
|
1772
2097
|
}
|
|
1773
2098
|
|
|
1774
2099
|
private async handleTaskComment(
|
|
@@ -2270,12 +2595,22 @@ export class ParallAgentGateway {
|
|
|
2270
2595
|
|
|
2271
2596
|
processed++;
|
|
2272
2597
|
try {
|
|
2273
|
-
|
|
2598
|
+
const ackItem = () => {
|
|
2599
|
+
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
|
|
2600
|
+
};
|
|
2274
2601
|
if (item.event_type === 'task_assign' && item.task_id) {
|
|
2275
2602
|
try {
|
|
2276
|
-
|
|
2277
|
-
item.
|
|
2278
|
-
|
|
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,
|
|
2279
2614
|
);
|
|
2280
2615
|
} catch (err: unknown) {
|
|
2281
2616
|
this.opts.log?.warn(
|
|
@@ -2285,10 +2620,18 @@ export class ParallAgentGateway {
|
|
|
2285
2620
|
}
|
|
2286
2621
|
} else if (item.event_type === 'task_update' && item.task_id) {
|
|
2287
2622
|
try {
|
|
2288
|
-
|
|
2289
|
-
item.
|
|
2290
|
-
|
|
2291
|
-
|
|
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,
|
|
2292
2635
|
);
|
|
2293
2636
|
} catch (err: unknown) {
|
|
2294
2637
|
this.opts.log?.warn(
|
|
@@ -2297,72 +2640,58 @@ export class ParallAgentGateway {
|
|
|
2297
2640
|
continue;
|
|
2298
2641
|
}
|
|
2299
2642
|
} else if (item.event_type === 'task_comment' && item.source_id && item.task_id) {
|
|
2300
|
-
|
|
2301
|
-
item.
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
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,
|
|
2305
2653
|
);
|
|
2306
2654
|
} else if (item.event_type === 'wiki_comment' && item.source_id) {
|
|
2307
|
-
|
|
2308
|
-
item.
|
|
2309
|
-
item.actor_id,
|
|
2310
|
-
|
|
2655
|
+
await this.consumeTypedDispatch(
|
|
2656
|
+
{ dispatchEventId: item.id },
|
|
2657
|
+
() => this.handleWikiComment(item.source_id, item.actor_id, item.delivery_reason),
|
|
2658
|
+
ackItem,
|
|
2311
2659
|
);
|
|
2312
2660
|
} else if (item.event_type === 'schedule.fire' && item.source_id) {
|
|
2313
|
-
|
|
2661
|
+
await this.consumeTypedDispatch(
|
|
2662
|
+
{ dispatchEventId: item.id },
|
|
2663
|
+
() => this.fetchAndHandleScheduleFire(item.source_id, item.actor_id),
|
|
2664
|
+
ackItem,
|
|
2665
|
+
);
|
|
2314
2666
|
} else if (item.event_type === 'external_trigger' && item.source_id) {
|
|
2315
|
-
|
|
2667
|
+
await this.consumeTypedDispatch(
|
|
2668
|
+
{ dispatchEventId: item.id },
|
|
2669
|
+
() => this.fetchAndHandleExternalTriggerRun(item.source_id),
|
|
2670
|
+
ackItem,
|
|
2671
|
+
);
|
|
2316
2672
|
} else if (item.event_type === 'channel_message' && item.source_id) {
|
|
2317
|
-
|
|
2673
|
+
await this.consumeTypedDispatch(
|
|
2674
|
+
{ dispatchEventId: item.id },
|
|
2675
|
+
() => this.fetchAndHandleChannelMessage(item.source_id),
|
|
2676
|
+
ackItem,
|
|
2677
|
+
);
|
|
2318
2678
|
} else if (item.event_type === 'approval_decided' && item.source_id) {
|
|
2319
|
-
|
|
2320
|
-
item.
|
|
2321
|
-
|
|
2322
|
-
|
|
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,
|
|
2323
2688
|
);
|
|
2324
2689
|
} else if (item.event_type === 'message' && item.source_id && item.chat_id) {
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
} catch (err: unknown) {
|
|
2331
|
-
const status = (err as { status?: number })?.status;
|
|
2332
|
-
if (status === 404) {
|
|
2333
|
-
msg = null;
|
|
2334
|
-
} else {
|
|
2335
|
-
msgFetchFailed = true;
|
|
2336
|
-
this.opts.log?.warn(
|
|
2337
|
-
`catch-up message fetch failed for ${item.source_id}, leaving pending: ${String(err)}`,
|
|
2338
|
-
);
|
|
2339
|
-
}
|
|
2340
|
-
}
|
|
2341
|
-
if (msgFetchFailed) {
|
|
2342
|
-
this.dispatchedMessages.delete(item.source_id);
|
|
2343
|
-
continue;
|
|
2344
|
-
}
|
|
2345
|
-
if (!msg || msg.sender_id === this.opts.agentUserId) {
|
|
2346
|
-
this.dispatchedMessages.delete(item.source_id);
|
|
2347
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
|
|
2348
|
-
continue;
|
|
2349
|
-
}
|
|
2350
|
-
|
|
2351
|
-
const decision = await this.buildMessageDispatchDecision(item.chat_id, msg);
|
|
2352
|
-
if (decision.action === 'retry') {
|
|
2353
|
-
this.dispatchedMessages.delete(item.source_id);
|
|
2354
|
-
continue;
|
|
2355
|
-
}
|
|
2356
|
-
if (decision.action === 'skip') {
|
|
2357
|
-
this.dispatchedMessages.delete(item.source_id);
|
|
2358
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
|
|
2359
|
-
continue;
|
|
2360
|
-
}
|
|
2361
|
-
|
|
2362
|
-
dispatched = await this.handleInboundEvent(decision.event);
|
|
2363
|
-
}
|
|
2364
|
-
if (dispatched) {
|
|
2365
|
-
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
|
+
});
|
|
2366
2695
|
}
|
|
2367
2696
|
} catch (err) {
|
|
2368
2697
|
this.opts.log?.warn(
|
|
@@ -2450,6 +2779,20 @@ export class ParallAgentGateway {
|
|
|
2450
2779
|
this.abortFork(targetId, 'ws reconnect');
|
|
2451
2780
|
}
|
|
2452
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
|
+
}
|
|
2453
2796
|
const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
|
|
2454
2797
|
try {
|
|
2455
2798
|
const count = await fetchAllChats(client, config.org_id, this.chatInfoMap);
|
|
@@ -2534,6 +2877,14 @@ export class ParallAgentGateway {
|
|
|
2534
2877
|
|
|
2535
2878
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
2536
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
|
+
|
|
2537
2888
|
await this.opts.onBeforeDisconnect?.();
|
|
2538
2889
|
|
|
2539
2890
|
this.opts.ws.disconnect();
|