@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/dist/gateway-base.js
CHANGED
|
@@ -3,6 +3,8 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import { ApiError, MENTION_ALL_USER_ID } from '@parall/sdk';
|
|
5
5
|
import { buildEventBody, buildEventBodyForForkResult, buildForkResultPrefix, buildForkScopePrefix, } from './event-format.js';
|
|
6
|
+
import { consumeMessageWorkItem, consumeTypedDispatch, dispatchLaneGroup, } from './gateway-lane-flow.js';
|
|
7
|
+
import { LaneLedger } from './lane-ledger.js';
|
|
6
8
|
import { routeTrigger } from './routing.js';
|
|
7
9
|
import { clearDispatchMessageId, clearDispatchMetrics, clearDispatchNoReply, clearSessionMessageId, getDispatchMetrics, recordDeliverText, recordMessageSend, recordNoReply, recordToolCall, resetDispatchMetrics, setDispatchMessageId, setDispatchNoReply, setSessionChatId, setSessionMessageId, } from './session-state.js';
|
|
8
10
|
import { isParallSendCommand, isParallNoReplyCommand, extractShellCommand, } from './bridge-workspace.js';
|
|
@@ -143,6 +145,14 @@ export class ParallAgentGateway {
|
|
|
143
145
|
inFlightDispatches = 0;
|
|
144
146
|
drainResolvers = [];
|
|
145
147
|
pendingRestartNotification = null;
|
|
148
|
+
laneLedger;
|
|
149
|
+
// Sticky fallback: flipped when the server predates the ledger (claim
|
|
150
|
+
// endpoint 404) so every subsequent dispatch uses the legacy flow.
|
|
151
|
+
ledgerDisabled = false;
|
|
152
|
+
// Group key of the group currently being dispatched on main — lane-aware
|
|
153
|
+
// (targetId + thread), unlike mainCurrentTargetId which stays chat-level
|
|
154
|
+
// for fork routing decisions.
|
|
155
|
+
mainCurrentGroupKey;
|
|
146
156
|
DISPATCHED_MESSAGES_CAP = 5000;
|
|
147
157
|
// SHUTDOWN_DEADLINE_MS is read by waitForDrain via the configured value
|
|
148
158
|
// below — kept as instance state so per-runtime configs can override it
|
|
@@ -152,6 +162,14 @@ export class ParallAgentGateway {
|
|
|
152
162
|
DISPATCH_DEADLINE_MS;
|
|
153
163
|
constructor(opts) {
|
|
154
164
|
this.opts = opts;
|
|
165
|
+
if (opts.dispatchContextDir) {
|
|
166
|
+
this.laneLedger = new LaneLedger({
|
|
167
|
+
client: opts.client,
|
|
168
|
+
orgId: opts.config.org_id,
|
|
169
|
+
contextDir: opts.dispatchContextDir,
|
|
170
|
+
log: opts.log,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
155
173
|
this.SHUTDOWN_DEADLINE_MS = opts.shutdownDeadlineMs ?? 60_000;
|
|
156
174
|
this.FORK_DEADLINE_MS = opts.forkDeadlineMs ?? 2 * 60 * 60_000;
|
|
157
175
|
this.DISPATCH_DEADLINE_MS = opts.dispatchDeadlineMs ?? 20 * 60_000;
|
|
@@ -228,15 +246,26 @@ export class ParallAgentGateway {
|
|
|
228
246
|
if (data.status !== 'todo' && data.status !== 'in_progress')
|
|
229
247
|
return;
|
|
230
248
|
try {
|
|
231
|
-
|
|
232
|
-
|
|
249
|
+
// Prefer the exact WorkItem id the server threads through the event —
|
|
250
|
+
// a task PATCH can enqueue sibling task_assign + task_update rows
|
|
251
|
+
// under the same (task_activity, task_id) source tuple, and source-
|
|
252
|
+
// level claim/ack would consume or clear the wrong sibling.
|
|
253
|
+
await this.consumeTypedDispatch(data.dispatch_event_id
|
|
254
|
+
? { dispatchEventId: data.dispatch_event_id }
|
|
255
|
+
: { sourceType: 'task_activity', sourceId: data.id }, (dispatchEventId) => this.handleTaskAssignment(data, data.id, dispatchEventId), (dispatchEventId) => {
|
|
256
|
+
if (dispatchEventId) {
|
|
257
|
+
this.opts.client
|
|
258
|
+
.ackDispatchByID(this.opts.config.org_id, dispatchEventId)
|
|
259
|
+
.catch(() => { });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
233
262
|
this.opts.client
|
|
234
263
|
.ackDispatch(this.opts.config.org_id, {
|
|
235
264
|
source_type: 'task_activity',
|
|
236
265
|
source_id: data.id,
|
|
237
266
|
})
|
|
238
267
|
.catch(() => { });
|
|
239
|
-
}
|
|
268
|
+
});
|
|
240
269
|
}
|
|
241
270
|
catch (err) {
|
|
242
271
|
this.opts.log?.error(`task dispatch failed for ${data.id}: ${String(err)}`);
|
|
@@ -247,10 +276,9 @@ export class ParallAgentGateway {
|
|
|
247
276
|
if (!data.source_id || !data.task_id)
|
|
248
277
|
return;
|
|
249
278
|
try {
|
|
250
|
-
|
|
251
|
-
if (dispatched) {
|
|
279
|
+
await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.handleTaskComment(data.source_id, data.task_id ?? '', data.actor_id, data.delivery_reason), () => {
|
|
252
280
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
|
|
253
|
-
}
|
|
281
|
+
});
|
|
254
282
|
}
|
|
255
283
|
catch (err) {
|
|
256
284
|
this.opts.log?.error(`task comment dispatch failed for ${data.source_id}: ${String(err)}`);
|
|
@@ -260,10 +288,9 @@ export class ParallAgentGateway {
|
|
|
260
288
|
if (!data.source_id)
|
|
261
289
|
return;
|
|
262
290
|
try {
|
|
263
|
-
|
|
264
|
-
if (dispatched) {
|
|
291
|
+
await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.handleWikiComment(data.source_id, data.actor_id, data.delivery_reason), () => {
|
|
265
292
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
|
|
266
|
-
}
|
|
293
|
+
});
|
|
267
294
|
}
|
|
268
295
|
catch (err) {
|
|
269
296
|
this.opts.log?.error(`wiki comment dispatch failed for ${data.source_id}: ${String(err)}`);
|
|
@@ -273,10 +300,12 @@ export class ParallAgentGateway {
|
|
|
273
300
|
if (!data.task_id)
|
|
274
301
|
return;
|
|
275
302
|
try {
|
|
276
|
-
|
|
277
|
-
|
|
303
|
+
await this.consumeTypedDispatch({ dispatchEventId: data.id }, (dispatchEventId) => this.handleTaskDispatch(data.task_id ?? '', data.source_id ?? data.task_id ?? '', {
|
|
304
|
+
allowCreator: true,
|
|
305
|
+
dispatchEventId,
|
|
306
|
+
}), () => {
|
|
278
307
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
|
|
279
|
-
}
|
|
308
|
+
});
|
|
280
309
|
}
|
|
281
310
|
catch (err) {
|
|
282
311
|
this.opts.log?.error(`task update dispatch failed for ${data.task_id}: ${String(err)}`);
|
|
@@ -286,10 +315,9 @@ export class ParallAgentGateway {
|
|
|
286
315
|
if (!data.source_id)
|
|
287
316
|
return;
|
|
288
317
|
try {
|
|
289
|
-
|
|
290
|
-
if (dispatched) {
|
|
318
|
+
await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleScheduleFire(data.source_id, data.actor_id), () => {
|
|
291
319
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
|
|
292
|
-
}
|
|
320
|
+
});
|
|
293
321
|
}
|
|
294
322
|
catch (err) {
|
|
295
323
|
this.opts.log?.error(`schedule fire dispatch failed for ${data.source_id}: ${String(err)}`);
|
|
@@ -299,10 +327,9 @@ export class ParallAgentGateway {
|
|
|
299
327
|
if (!data.source_id)
|
|
300
328
|
return;
|
|
301
329
|
try {
|
|
302
|
-
|
|
303
|
-
if (dispatched) {
|
|
330
|
+
await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleExternalTriggerRun(data.source_id), () => {
|
|
304
331
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
|
|
305
|
-
}
|
|
332
|
+
});
|
|
306
333
|
}
|
|
307
334
|
catch (err) {
|
|
308
335
|
this.opts.log?.error(`external trigger dispatch failed for ${data.source_id}: ${String(err)}`);
|
|
@@ -312,10 +339,9 @@ export class ParallAgentGateway {
|
|
|
312
339
|
if (!data.source_id)
|
|
313
340
|
return;
|
|
314
341
|
try {
|
|
315
|
-
|
|
316
|
-
if (dispatched) {
|
|
342
|
+
await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleChannelMessage(data.source_id), () => {
|
|
317
343
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
|
|
318
|
-
}
|
|
344
|
+
});
|
|
319
345
|
}
|
|
320
346
|
catch (err) {
|
|
321
347
|
this.opts.log?.error(`channel message dispatch failed for ${data.source_id}: ${String(err)}`);
|
|
@@ -325,15 +351,28 @@ export class ParallAgentGateway {
|
|
|
325
351
|
if (!data.source_id)
|
|
326
352
|
return;
|
|
327
353
|
try {
|
|
328
|
-
|
|
329
|
-
if (dispatched) {
|
|
354
|
+
await this.consumeTypedDispatch({ dispatchEventId: data.id }, () => this.fetchAndHandleApprovalDecided(data.source_id, data.actor_id, data.chat_id ?? null), () => {
|
|
330
355
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
|
|
331
|
-
}
|
|
356
|
+
});
|
|
332
357
|
}
|
|
333
358
|
catch (err) {
|
|
334
359
|
this.opts.log?.error(`approval decided dispatch failed for ${data.source_id}: ${String(err)}`);
|
|
335
360
|
}
|
|
336
361
|
}
|
|
362
|
+
else if (data.event_type === 'message' &&
|
|
363
|
+
this.laneLedger &&
|
|
364
|
+
data.source_id &&
|
|
365
|
+
data.chat_id) {
|
|
366
|
+
// Ledger re-drive hint: a pending message WorkItem re-published after
|
|
367
|
+
// a same-target lane completed (claim previously refused, or a steer
|
|
368
|
+
// failed). Live first delivery stays on the message.new handler.
|
|
369
|
+
try {
|
|
370
|
+
await this.handleMessageRedrive(data);
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
this.opts.log?.error(`message re-drive failed for ${data.source_id}: ${String(err)}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
337
376
|
else if (data.event_type !== 'message' && data.event_type !== 'task_assign') {
|
|
338
377
|
// Truly unknown event_type — log so a newly-added dispatch type
|
|
339
378
|
// not yet wired here surfaces during runtime testing. "message"
|
|
@@ -376,6 +415,43 @@ export class ParallAgentGateway {
|
|
|
376
415
|
source_id: sourceId,
|
|
377
416
|
});
|
|
378
417
|
}
|
|
418
|
+
/** True when this event's lifecycle is owned by the dispatch lane ledger. */
|
|
419
|
+
usesLaneLedger(event) {
|
|
420
|
+
return this.laneLedger != null && !this.ledgerDisabled && this.laneLedger.handles(event);
|
|
421
|
+
}
|
|
422
|
+
disableLedger(reason) {
|
|
423
|
+
if (this.ledgerDisabled)
|
|
424
|
+
return;
|
|
425
|
+
this.ledgerDisabled = true;
|
|
426
|
+
this.opts.log?.warn(`dispatch ledger unavailable (${reason}) — falling back to legacy received/ack flow`);
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Buffer grouping key. Lane-ledger message events group by full lane
|
|
430
|
+
* identity (chat + thread) so a channel lane and a thread lane in the same
|
|
431
|
+
* chat dispatch as separate turns with separate claims; everything else
|
|
432
|
+
* keeps the historical chat-level grouping.
|
|
433
|
+
*/
|
|
434
|
+
dispatchGroupKey(event) {
|
|
435
|
+
if (this.usesLaneLedger(event)) {
|
|
436
|
+
// MUST be the lane identity itself (lane-key SSOT): the mid-turn
|
|
437
|
+
// injection gate compares this against mainCurrentGroupKey, and a
|
|
438
|
+
// grouping key that drifted from lane identity would fold two lanes
|
|
439
|
+
// into one turn.
|
|
440
|
+
return this.laneLedger.laneKeyFor(event);
|
|
441
|
+
}
|
|
442
|
+
return event.targetId;
|
|
443
|
+
}
|
|
444
|
+
// Lane-flow protocols live in gateway-lane-flow.ts; these thin delegates
|
|
445
|
+
// keep call sites and tests on the class surface.
|
|
446
|
+
laneFlowHost() {
|
|
447
|
+
return this;
|
|
448
|
+
}
|
|
449
|
+
dispatchLaneGroup(opts) {
|
|
450
|
+
return dispatchLaneGroup(this.laneFlowHost(), opts);
|
|
451
|
+
}
|
|
452
|
+
consumeTypedDispatch(ref, run, ack) {
|
|
453
|
+
return consumeTypedDispatch(this.laneFlowHost(), ref, run, ack);
|
|
454
|
+
}
|
|
379
455
|
buildDispatchContext(event, sessionKey) {
|
|
380
456
|
const binding = this.sessionBindings.get(sessionKey);
|
|
381
457
|
return {
|
|
@@ -392,6 +468,7 @@ export class ParallAgentGateway {
|
|
|
392
468
|
noReply: event.noReply ?? false,
|
|
393
469
|
contextFilePath: this.opts.contextFilePathForSession?.(sessionKey),
|
|
394
470
|
stepIdFilePath: this.opts.stepIdFilePathForSession?.(sessionKey),
|
|
471
|
+
contextDirPath: this.opts.dispatchContextDir,
|
|
395
472
|
client: this.opts.client,
|
|
396
473
|
log: this.opts.log,
|
|
397
474
|
};
|
|
@@ -462,7 +539,7 @@ export class ParallAgentGateway {
|
|
|
462
539
|
this.opts.log?.warn(`failed to create input step: ${String(err)}`);
|
|
463
540
|
}
|
|
464
541
|
}
|
|
465
|
-
async createRuntimeStep(sessionId, event, runtimeEvent, stepIdFilePath, contextFilePath) {
|
|
542
|
+
async createRuntimeStep(sessionId, event, runtimeEvent, stepIdFilePath, contextFilePath, laneContextFilePath) {
|
|
466
543
|
const target = resolveStepTarget(event);
|
|
467
544
|
try {
|
|
468
545
|
switch (runtimeEvent.type) {
|
|
@@ -509,6 +586,9 @@ export class ParallAgentGateway {
|
|
|
509
586
|
else if (stepIdFilePath) {
|
|
510
587
|
this.writeStepIdFile(stepIdFilePath, step.id);
|
|
511
588
|
}
|
|
589
|
+
if (laneContextFilePath) {
|
|
590
|
+
this.updateContextFileStepId(laneContextFilePath, step.id);
|
|
591
|
+
}
|
|
512
592
|
break;
|
|
513
593
|
}
|
|
514
594
|
case 'tool_result':
|
|
@@ -532,6 +612,9 @@ export class ParallAgentGateway {
|
|
|
532
612
|
else if (stepIdFilePath) {
|
|
533
613
|
this.clearStepIdFile(stepIdFilePath);
|
|
534
614
|
}
|
|
615
|
+
if (laneContextFilePath) {
|
|
616
|
+
this.updateContextFileStepId(laneContextFilePath, null);
|
|
617
|
+
}
|
|
535
618
|
break;
|
|
536
619
|
case 'error':
|
|
537
620
|
await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
|
|
@@ -605,7 +688,7 @@ export class ParallAgentGateway {
|
|
|
605
688
|
await this.createInputStep(sessionId, event);
|
|
606
689
|
}
|
|
607
690
|
}
|
|
608
|
-
async bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath) {
|
|
691
|
+
async bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath, laneContextFilePath) {
|
|
609
692
|
const runtimeLaneKey = runtimeEvent.runtimeLaneKey || sessionKey;
|
|
610
693
|
const existing = this.sessionBindings.get(sessionKey);
|
|
611
694
|
if (existing &&
|
|
@@ -654,6 +737,9 @@ export class ParallAgentGateway {
|
|
|
654
737
|
if (contextFilePath) {
|
|
655
738
|
this.updateContextFileSessionId(contextFilePath, session.id);
|
|
656
739
|
}
|
|
740
|
+
if (laneContextFilePath) {
|
|
741
|
+
this.updateContextFileSessionId(laneContextFilePath, session.id);
|
|
742
|
+
}
|
|
657
743
|
await this.opts.onSessionBinding?.(binding);
|
|
658
744
|
return binding;
|
|
659
745
|
}
|
|
@@ -683,14 +769,32 @@ export class ParallAgentGateway {
|
|
|
683
769
|
const dispatchContext = this.buildDispatchContext(event, sessionKey);
|
|
684
770
|
const contextFilePath = dispatchContext.contextFilePath;
|
|
685
771
|
const stepIdFilePath = dispatchContext.stepIdFilePath;
|
|
772
|
+
// Per-lane context (PRLL_CONTEXT_DIR contract): additive dispatch/lane
|
|
773
|
+
// fields ride along in both files; the per-session file stays as the
|
|
774
|
+
// PRLL_CONTEXT_FILE compat read path.
|
|
775
|
+
const activeLane = this.ledgerDisabled ? undefined : this.laneLedger?.getForEvent(event);
|
|
776
|
+
const laneContextFilePath = activeLane
|
|
777
|
+
? this.laneLedger?.laneContextPath(activeLane)
|
|
778
|
+
: undefined;
|
|
779
|
+
const contextBody = {
|
|
780
|
+
session_id: dispatchContext.sessionId ?? null,
|
|
781
|
+
chat_id: dispatchContext.chatId ?? null,
|
|
782
|
+
trigger_message_id: dispatchContext.triggerMessageId ?? null,
|
|
783
|
+
no_reply: dispatchContext.noReply,
|
|
784
|
+
step_id: null,
|
|
785
|
+
dispatch_event_id: activeLane?.typedDispatchEventId ?? activeLane?.folded.get(event.messageId) ?? null,
|
|
786
|
+
lane: activeLane?.lane ?? null,
|
|
787
|
+
target_uri: activeLane?.targetUri ?? null,
|
|
788
|
+
thread_root_id: activeLane?.threadRootId ?? null,
|
|
789
|
+
// Typed binding hint for the CLI: which task this dispatch is about
|
|
790
|
+
// (parall task update attaches the typed effect only on a match).
|
|
791
|
+
task_id: event.type === 'task' ? event.targetId : null,
|
|
792
|
+
};
|
|
686
793
|
if (contextFilePath) {
|
|
687
|
-
this.writeContextFile(contextFilePath,
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
no_reply: dispatchContext.noReply,
|
|
692
|
-
step_id: null,
|
|
693
|
-
});
|
|
794
|
+
this.writeContextFile(contextFilePath, contextBody);
|
|
795
|
+
}
|
|
796
|
+
if (laneContextFilePath) {
|
|
797
|
+
this.writeContextFile(laneContextFilePath, contextBody);
|
|
694
798
|
}
|
|
695
799
|
// sync: no await between the shuttingDown check above and this increment
|
|
696
800
|
// — JS event loop is single-threaded, so shutdown() cannot interleave
|
|
@@ -723,7 +827,7 @@ export class ParallAgentGateway {
|
|
|
723
827
|
})) {
|
|
724
828
|
if (runtimeEvent.type === 'runtime_session') {
|
|
725
829
|
const priorAgentSessionId = binding?.agentSessionId;
|
|
726
|
-
binding = await this.bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath);
|
|
830
|
+
binding = await this.bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath, laneContextFilePath);
|
|
727
831
|
if (event.targetType === 'channel_conversation' &&
|
|
728
832
|
binding.agentSessionId !== priorAgentSessionId) {
|
|
729
833
|
// Record the durable chv_ ↔ ase_ mapping (ops drill-down from a
|
|
@@ -766,6 +870,12 @@ export class ParallAgentGateway {
|
|
|
766
870
|
await this.createInputStep(binding.agentSessionId, event);
|
|
767
871
|
inputStepsCreated = true;
|
|
768
872
|
}
|
|
873
|
+
// Long-turn keepalive: any runtime activity renews the lane lease
|
|
874
|
+
// (throttled in the ledger) so a legitimately long turn is not
|
|
875
|
+
// dethroned at TTL.
|
|
876
|
+
if (activeLane && !this.ledgerDisabled) {
|
|
877
|
+
this.laneLedger?.maybeRenew(activeLane);
|
|
878
|
+
}
|
|
769
879
|
if (captureText && runtimeEvent.type === 'text' && runtimeEvent.text) {
|
|
770
880
|
captureText.push(runtimeEvent.text);
|
|
771
881
|
}
|
|
@@ -786,7 +896,7 @@ export class ParallAgentGateway {
|
|
|
786
896
|
pendingSendCallIds.delete(runtimeEvent.callId)) {
|
|
787
897
|
recordMessageSend(sessionKey, !runtimeEvent.error);
|
|
788
898
|
}
|
|
789
|
-
await this.createRuntimeStep(binding.agentSessionId, event, runtimeEvent, stepIdFilePath, contextFilePath);
|
|
899
|
+
await this.createRuntimeStep(binding.agentSessionId, event, runtimeEvent, stepIdFilePath, contextFilePath, laneContextFilePath);
|
|
790
900
|
}
|
|
791
901
|
if (!binding) {
|
|
792
902
|
binding = this.sessionBindings.get(sessionKey);
|
|
@@ -809,7 +919,7 @@ export class ParallAgentGateway {
|
|
|
809
919
|
await this.createRuntimeStep(binding.agentSessionId, event, {
|
|
810
920
|
type: 'error',
|
|
811
921
|
message: `Dispatch failed: ${String(err)}`,
|
|
812
|
-
}, stepIdFilePath, contextFilePath);
|
|
922
|
+
}, stepIdFilePath, contextFilePath, laneContextFilePath);
|
|
813
923
|
}
|
|
814
924
|
catch (stepErr) {
|
|
815
925
|
if (this.isSessionNotLiveError(stepErr))
|
|
@@ -865,6 +975,9 @@ export class ParallAgentGateway {
|
|
|
865
975
|
else if (stepIdFilePath) {
|
|
866
976
|
this.clearStepIdFile(stepIdFilePath);
|
|
867
977
|
}
|
|
978
|
+
if (laneContextFilePath) {
|
|
979
|
+
this.updateContextFileStepId(laneContextFilePath, null);
|
|
980
|
+
}
|
|
868
981
|
this.inFlightDispatches--;
|
|
869
982
|
if (this.inFlightDispatches === 0 && this.drainResolvers.length > 0) {
|
|
870
983
|
const resolvers = this.drainResolvers.splice(0);
|
|
@@ -930,13 +1043,44 @@ export class ParallAgentGateway {
|
|
|
930
1043
|
item.resolve(false);
|
|
931
1044
|
break;
|
|
932
1045
|
}
|
|
933
|
-
|
|
1046
|
+
// Lane-ledger message events batch per lane identity (chat + thread)
|
|
1047
|
+
// so a fork's claim/steer/complete always addresses one lane.
|
|
1048
|
+
let items;
|
|
1049
|
+
const head = fork.queue[0];
|
|
1050
|
+
if (head && this.usesLaneLedger(head.event)) {
|
|
1051
|
+
const headKey = this.dispatchGroupKey(head.event);
|
|
1052
|
+
const splitAt = fork.queue.findIndex((it) => this.dispatchGroupKey(it.event) !== headKey);
|
|
1053
|
+
items = splitAt === -1 ? fork.queue.splice(0) : fork.queue.splice(0, splitAt);
|
|
1054
|
+
}
|
|
1055
|
+
else {
|
|
1056
|
+
items = fork.queue.splice(0);
|
|
1057
|
+
}
|
|
934
1058
|
const events = items.map((item) => item.event);
|
|
935
1059
|
const last = events[events.length - 1];
|
|
936
1060
|
const earlier = events.slice(0, -1);
|
|
937
1061
|
try {
|
|
938
1062
|
const batchText = [];
|
|
939
|
-
|
|
1063
|
+
let dispatched;
|
|
1064
|
+
if (this.usesLaneLedger(last)) {
|
|
1065
|
+
const outcome = await this.dispatchLaneGroup({
|
|
1066
|
+
events,
|
|
1067
|
+
sessionKey: fork.fork.sessionKey,
|
|
1068
|
+
body: buildForkScopePrefix(last) + buildEventBody(last),
|
|
1069
|
+
earlier,
|
|
1070
|
+
captureText: batchText,
|
|
1071
|
+
hasMoreLocal: () => fork.queue.length > 0,
|
|
1072
|
+
});
|
|
1073
|
+
if (outcome === 'foreign') {
|
|
1074
|
+
// Another pod owns the lane — the events stay pending server-side.
|
|
1075
|
+
for (const item of items)
|
|
1076
|
+
item.resolve(false);
|
|
1077
|
+
break;
|
|
1078
|
+
}
|
|
1079
|
+
dispatched = outcome === 'dispatched';
|
|
1080
|
+
}
|
|
1081
|
+
else {
|
|
1082
|
+
dispatched = await this.runDispatch(last, fork.fork.sessionKey, buildForkScopePrefix(last) + buildEventBody(last), earlier, batchText);
|
|
1083
|
+
}
|
|
940
1084
|
if (!dispatched) {
|
|
941
1085
|
// Shutdown short-circuit — resolve un-acked so the server requeues
|
|
942
1086
|
// for the replacement pod and stop draining further items.
|
|
@@ -1052,9 +1196,10 @@ export class ParallAgentGateway {
|
|
|
1052
1196
|
this.opts.log?.info(`drainMainBuffer halted (shutting down) — ${this.dispatchState.mainBuffer.length} buffered, ${this.dispatchState.pendingForkResults.length} pending fork results left for catch-up`);
|
|
1053
1197
|
break;
|
|
1054
1198
|
}
|
|
1055
|
-
const
|
|
1199
|
+
const groupKey = this.dispatchGroupKey(this.dispatchState.mainBuffer[0]);
|
|
1056
1200
|
const events = [];
|
|
1057
|
-
while (this.dispatchState.mainBuffer[0]
|
|
1201
|
+
while (this.dispatchState.mainBuffer[0] &&
|
|
1202
|
+
this.dispatchGroupKey(this.dispatchState.mainBuffer[0]) === groupKey) {
|
|
1058
1203
|
events.push(this.dispatchState.mainBuffer.shift());
|
|
1059
1204
|
}
|
|
1060
1205
|
const event = events[events.length - 1];
|
|
@@ -1065,7 +1210,41 @@ export class ParallAgentGateway {
|
|
|
1065
1210
|
: this.dispatchState.pendingForkResults.splice(0);
|
|
1066
1211
|
const forkPrefix = buildForkResultPrefix(pendingFork);
|
|
1067
1212
|
this.dispatchState.mainCurrentTargetId = event.targetId;
|
|
1213
|
+
this.mainCurrentGroupKey = groupKey;
|
|
1068
1214
|
this.dispatchState.mainPreDispatchBranchPoint = this.opts.dispatchAdapter.getBranchPoint?.(this.opts.runtimeKey);
|
|
1215
|
+
if (this.usesLaneLedger(event)) {
|
|
1216
|
+
let outcome;
|
|
1217
|
+
try {
|
|
1218
|
+
outcome = await this.dispatchLaneGroup({
|
|
1219
|
+
events,
|
|
1220
|
+
sessionKey: this.opts.runtimeKey,
|
|
1221
|
+
body: forkPrefix + buildEventBody(event),
|
|
1222
|
+
earlier,
|
|
1223
|
+
hasMoreLocal: () => this.dispatchState.mainBuffer.some((e) => this.dispatchGroupKey(e) === groupKey),
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
catch (err) {
|
|
1227
|
+
// The lane was released inside dispatchLaneGroup — members are
|
|
1228
|
+
// pending again server-side; drop them locally and move on.
|
|
1229
|
+
this.opts.log?.error(`lane dispatch failed for ${event.messageId}: ${String(err)}`);
|
|
1230
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1231
|
+
for (const ev of events)
|
|
1232
|
+
this.dispatchedMessages.delete(ev.messageId);
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
if (outcome === 'shutdown') {
|
|
1236
|
+
this.dispatchState.mainBuffer.unshift(...events);
|
|
1237
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1238
|
+
break;
|
|
1239
|
+
}
|
|
1240
|
+
if (outcome === 'foreign') {
|
|
1241
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
// Dispatched — resolution happened server-side (reply cover or
|
|
1245
|
+
// no_action sweep); no legacy acks.
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1069
1248
|
try {
|
|
1070
1249
|
await this.emitDispatchReceived(event);
|
|
1071
1250
|
}
|
|
@@ -1101,6 +1280,7 @@ export class ParallAgentGateway {
|
|
|
1101
1280
|
this.draining = false;
|
|
1102
1281
|
this.dispatchState.mainDispatching = false;
|
|
1103
1282
|
this.dispatchState.mainCurrentTargetId = undefined;
|
|
1283
|
+
this.mainCurrentGroupKey = undefined;
|
|
1104
1284
|
this.dispatchState.mainPreDispatchBranchPoint = undefined;
|
|
1105
1285
|
if (!this.shuttingDown && this.dispatchState.mainBuffer.length > 0) {
|
|
1106
1286
|
// Opportunistic re-drain — best-effort, not a recovery deadline, so it
|
|
@@ -1129,10 +1309,40 @@ export class ParallAgentGateway {
|
|
|
1129
1309
|
const forkPrefix = buildForkResultPrefix(pendingFork);
|
|
1130
1310
|
this.dispatchState.mainDispatching = true;
|
|
1131
1311
|
this.dispatchState.mainCurrentTargetId = event.targetId;
|
|
1312
|
+
this.mainCurrentGroupKey = this.dispatchGroupKey(event);
|
|
1132
1313
|
// Snapshot the on-disk branch point BEFORE runDispatch starts writing
|
|
1133
1314
|
// to the session file. Fork sessions created while main is in-flight
|
|
1134
1315
|
// use this to branch from the clean pre-dispatch state.
|
|
1135
1316
|
this.dispatchState.mainPreDispatchBranchPoint = this.opts.dispatchAdapter.getBranchPoint?.(this.opts.runtimeKey);
|
|
1317
|
+
if (this.usesLaneLedger(event)) {
|
|
1318
|
+
// Ledger flow: claim replaces mark-received; complete/reply replace
|
|
1319
|
+
// acks. A foreign incumbent leaves the event pending for re-drive.
|
|
1320
|
+
let outcome = 'shutdown';
|
|
1321
|
+
try {
|
|
1322
|
+
try {
|
|
1323
|
+
outcome = await this.dispatchLaneGroup({
|
|
1324
|
+
events: [event],
|
|
1325
|
+
sessionKey: this.opts.runtimeKey,
|
|
1326
|
+
body: forkPrefix + buildEventBody(event),
|
|
1327
|
+
earlier: [],
|
|
1328
|
+
hasMoreLocal: () => this.dispatchState.mainBuffer.some((e) => this.dispatchGroupKey(e) === this.dispatchGroupKey(event)),
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
catch (err) {
|
|
1332
|
+
// Same failure contract as the buffered-group path: accumulated
|
|
1333
|
+
// fork results must survive a failed turn for later replay.
|
|
1334
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1335
|
+
throw err;
|
|
1336
|
+
}
|
|
1337
|
+
if (outcome !== 'dispatched') {
|
|
1338
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
finally {
|
|
1342
|
+
await this.drainMainBuffer();
|
|
1343
|
+
}
|
|
1344
|
+
return outcome === 'dispatched';
|
|
1345
|
+
}
|
|
1136
1346
|
try {
|
|
1137
1347
|
await this.emitDispatchReceived(event);
|
|
1138
1348
|
}
|
|
@@ -1140,6 +1350,7 @@ export class ParallAgentGateway {
|
|
|
1140
1350
|
this.opts.log?.warn?.(`mark-received failed, leaving unacked for retry: ${String(err)}`);
|
|
1141
1351
|
this.dispatchState.mainDispatching = false;
|
|
1142
1352
|
this.dispatchState.mainCurrentTargetId = undefined;
|
|
1353
|
+
this.mainCurrentGroupKey = undefined;
|
|
1143
1354
|
this.dispatchState.mainPreDispatchBranchPoint = undefined;
|
|
1144
1355
|
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1145
1356
|
return false;
|
|
@@ -1166,7 +1377,20 @@ export class ParallAgentGateway {
|
|
|
1166
1377
|
// arrival order is preserved and the event cannot be orphaned in a
|
|
1167
1378
|
// gap between the steer await and the push.
|
|
1168
1379
|
this.dispatchState.mainBuffer.push(event);
|
|
1169
|
-
if (this.
|
|
1380
|
+
if (this.usesLaneLedger(event)) {
|
|
1381
|
+
// Ledger flow: fold into the live lane server-side FIRST, then
|
|
1382
|
+
// inject. An un-folded injection is forbidden (the pending WorkItem
|
|
1383
|
+
// would re-drive after complete and be handled twice); a failed
|
|
1384
|
+
// fold leaves the event buffered — the drain claims it as its own
|
|
1385
|
+
// turn. Injection requires an exact lane match (same chat AND same
|
|
1386
|
+
// thread) — a thread message never rides a channel turn.
|
|
1387
|
+
if (this.mainCurrentGroupKey === this.dispatchGroupKey(event) &&
|
|
1388
|
+
(await this.laneLedger?.steerLive(event)) &&
|
|
1389
|
+
(await this.opts.dispatchAdapter.enqueueDuringDispatch?.(this.opts.runtimeKey, buildEventBody(event)))) {
|
|
1390
|
+
this.opts.log?.info(`steer folded+injected for ${event.messageId} (will drain for bookkeeping)`);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
else if (this.dispatchState.mainCurrentTargetId === event.targetId &&
|
|
1170
1394
|
(await this.opts.dispatchAdapter.enqueueDuringDispatch?.(this.opts.runtimeKey, buildEventBody(event)))) {
|
|
1171
1395
|
this.opts.log?.info(`steer injected for ${event.messageId} (will drain for bookkeeping)`);
|
|
1172
1396
|
}
|
|
@@ -1352,9 +1576,13 @@ export class ParallAgentGateway {
|
|
|
1352
1576
|
try {
|
|
1353
1577
|
const dispatched = await this.handleInboundEvent(event);
|
|
1354
1578
|
if (dispatched) {
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1579
|
+
// Ledger events resolve server-side (reply cover / no_action sweep);
|
|
1580
|
+
// the legacy by-source ack is only for non-ledger runtimes.
|
|
1581
|
+
if (!this.usesLaneLedger(event)) {
|
|
1582
|
+
this.opts.client
|
|
1583
|
+
.ackDispatch(this.opts.config.org_id, { source_type: 'message', source_id: data.id })
|
|
1584
|
+
.catch(() => { });
|
|
1585
|
+
}
|
|
1358
1586
|
}
|
|
1359
1587
|
else {
|
|
1360
1588
|
this.dispatchedMessages.delete(data.id);
|
|
@@ -1365,7 +1593,21 @@ export class ParallAgentGateway {
|
|
|
1365
1593
|
this.dispatchedMessages.delete(data.id);
|
|
1366
1594
|
}
|
|
1367
1595
|
}
|
|
1368
|
-
|
|
1596
|
+
// Ledger re-drive consumption: dispatch.new message hints re-enter the
|
|
1597
|
+
// shared WorkItem consumption path (same protocol as catch-up).
|
|
1598
|
+
async handleMessageRedrive(item) {
|
|
1599
|
+
if (!item.chat_id || !item.source_id)
|
|
1600
|
+
return;
|
|
1601
|
+
await this.consumeMessageWorkItem({
|
|
1602
|
+
id: item.id,
|
|
1603
|
+
source_id: item.source_id,
|
|
1604
|
+
chat_id: item.chat_id,
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
consumeMessageWorkItem(item) {
|
|
1608
|
+
return consumeMessageWorkItem(this.laneFlowHost(), item);
|
|
1609
|
+
}
|
|
1610
|
+
async handleTaskAssignment(task, ackSourceId, dispatchEventId) {
|
|
1369
1611
|
if (this.shuttingDown)
|
|
1370
1612
|
return false; // drain window — let server requeue via catch-up
|
|
1371
1613
|
const dedupeKey = `${task.id}:${task.updated_at}`;
|
|
@@ -1395,6 +1637,7 @@ export class ParallAgentGateway {
|
|
|
1395
1637
|
sentAt: task.updated_at ?? task.created_at,
|
|
1396
1638
|
ackSourceType: 'task_activity',
|
|
1397
1639
|
ackSourceId,
|
|
1640
|
+
dispatchEventId,
|
|
1398
1641
|
};
|
|
1399
1642
|
const dispatched = await this.handleInboundEvent(event);
|
|
1400
1643
|
if (!dispatched) {
|
|
@@ -1419,7 +1662,7 @@ export class ParallAgentGateway {
|
|
|
1419
1662
|
this.opts.log?.info(`skipping stale task dispatch ${ackSourceId ?? taskId} — assigned to ${task.assignee_id}, creator ${task.creator_id}`);
|
|
1420
1663
|
return true;
|
|
1421
1664
|
}
|
|
1422
|
-
return this.handleTaskAssignment(task, ackSourceId);
|
|
1665
|
+
return this.handleTaskAssignment(task, ackSourceId, opts.dispatchEventId);
|
|
1423
1666
|
}
|
|
1424
1667
|
async handleTaskComment(commentId, taskId, actorId, deliveryReason) {
|
|
1425
1668
|
if (this.shuttingDown)
|
|
@@ -1872,10 +2115,14 @@ export class ParallAgentGateway {
|
|
|
1872
2115
|
}
|
|
1873
2116
|
processed++;
|
|
1874
2117
|
try {
|
|
1875
|
-
|
|
2118
|
+
const ackItem = () => {
|
|
2119
|
+
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
|
|
2120
|
+
};
|
|
1876
2121
|
if (item.event_type === 'task_assign' && item.task_id) {
|
|
1877
2122
|
try {
|
|
1878
|
-
|
|
2123
|
+
await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.handleTaskDispatch(item.task_id ?? '', item.source_id ?? item.task_id ?? '', {
|
|
2124
|
+
dispatchEventId,
|
|
2125
|
+
}), ackItem);
|
|
1879
2126
|
}
|
|
1880
2127
|
catch (err) {
|
|
1881
2128
|
this.opts.log?.warn(`catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
|
|
@@ -1884,7 +2131,10 @@ export class ParallAgentGateway {
|
|
|
1884
2131
|
}
|
|
1885
2132
|
else if (item.event_type === 'task_update' && item.task_id) {
|
|
1886
2133
|
try {
|
|
1887
|
-
|
|
2134
|
+
await this.consumeTypedDispatch({ dispatchEventId: item.id }, (dispatchEventId) => this.handleTaskDispatch(item.task_id ?? '', item.source_id ?? item.task_id ?? '', {
|
|
2135
|
+
allowCreator: true,
|
|
2136
|
+
dispatchEventId,
|
|
2137
|
+
}), ackItem);
|
|
1888
2138
|
}
|
|
1889
2139
|
catch (err) {
|
|
1890
2140
|
this.opts.log?.warn(`catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
|
|
@@ -1892,64 +2142,29 @@ export class ParallAgentGateway {
|
|
|
1892
2142
|
}
|
|
1893
2143
|
}
|
|
1894
2144
|
else if (item.event_type === 'task_comment' && item.source_id && item.task_id) {
|
|
1895
|
-
|
|
2145
|
+
await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.handleTaskComment(item.source_id, item.task_id ?? '', item.actor_id, item.delivery_reason), ackItem);
|
|
1896
2146
|
}
|
|
1897
2147
|
else if (item.event_type === 'wiki_comment' && item.source_id) {
|
|
1898
|
-
|
|
2148
|
+
await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.handleWikiComment(item.source_id, item.actor_id, item.delivery_reason), ackItem);
|
|
1899
2149
|
}
|
|
1900
2150
|
else if (item.event_type === 'schedule.fire' && item.source_id) {
|
|
1901
|
-
|
|
2151
|
+
await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleScheduleFire(item.source_id, item.actor_id), ackItem);
|
|
1902
2152
|
}
|
|
1903
2153
|
else if (item.event_type === 'external_trigger' && item.source_id) {
|
|
1904
|
-
|
|
2154
|
+
await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleExternalTriggerRun(item.source_id), ackItem);
|
|
1905
2155
|
}
|
|
1906
2156
|
else if (item.event_type === 'channel_message' && item.source_id) {
|
|
1907
|
-
|
|
2157
|
+
await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleChannelMessage(item.source_id), ackItem);
|
|
1908
2158
|
}
|
|
1909
2159
|
else if (item.event_type === 'approval_decided' && item.source_id) {
|
|
1910
|
-
|
|
2160
|
+
await this.consumeTypedDispatch({ dispatchEventId: item.id }, () => this.fetchAndHandleApprovalDecided(item.source_id, item.actor_id, item.chat_id ?? null), ackItem);
|
|
1911
2161
|
}
|
|
1912
2162
|
else if (item.event_type === 'message' && item.source_id && item.chat_id) {
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
msg = await this.opts.client.getMessage(item.source_id);
|
|
1919
|
-
}
|
|
1920
|
-
catch (err) {
|
|
1921
|
-
const status = err?.status;
|
|
1922
|
-
if (status === 404) {
|
|
1923
|
-
msg = null;
|
|
1924
|
-
}
|
|
1925
|
-
else {
|
|
1926
|
-
msgFetchFailed = true;
|
|
1927
|
-
this.opts.log?.warn(`catch-up message fetch failed for ${item.source_id}, leaving pending: ${String(err)}`);
|
|
1928
|
-
}
|
|
1929
|
-
}
|
|
1930
|
-
if (msgFetchFailed) {
|
|
1931
|
-
this.dispatchedMessages.delete(item.source_id);
|
|
1932
|
-
continue;
|
|
1933
|
-
}
|
|
1934
|
-
if (!msg || msg.sender_id === this.opts.agentUserId) {
|
|
1935
|
-
this.dispatchedMessages.delete(item.source_id);
|
|
1936
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
|
|
1937
|
-
continue;
|
|
1938
|
-
}
|
|
1939
|
-
const decision = await this.buildMessageDispatchDecision(item.chat_id, msg);
|
|
1940
|
-
if (decision.action === 'retry') {
|
|
1941
|
-
this.dispatchedMessages.delete(item.source_id);
|
|
1942
|
-
continue;
|
|
1943
|
-
}
|
|
1944
|
-
if (decision.action === 'skip') {
|
|
1945
|
-
this.dispatchedMessages.delete(item.source_id);
|
|
1946
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
|
|
1947
|
-
continue;
|
|
1948
|
-
}
|
|
1949
|
-
dispatched = await this.handleInboundEvent(decision.event);
|
|
1950
|
-
}
|
|
1951
|
-
if (dispatched) {
|
|
1952
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
|
|
2163
|
+
await this.consumeMessageWorkItem({
|
|
2164
|
+
id: item.id,
|
|
2165
|
+
source_id: item.source_id,
|
|
2166
|
+
chat_id: item.chat_id,
|
|
2167
|
+
});
|
|
1953
2168
|
}
|
|
1954
2169
|
}
|
|
1955
2170
|
catch (err) {
|
|
@@ -2018,6 +2233,20 @@ export class ParallAgentGateway {
|
|
|
2018
2233
|
this.abortFork(targetId, 'ws reconnect');
|
|
2019
2234
|
}
|
|
2020
2235
|
}
|
|
2236
|
+
// Reconnect with nothing in flight: interrupted turns can't resume, so
|
|
2237
|
+
// hand their lane members back to the pending pool before catch-up
|
|
2238
|
+
// re-claims (an in-flight turn keeps its lanes — it is still the owner).
|
|
2239
|
+
if (this.laneLedger && this.inFlightDispatches === 0 && this.laneLedger.activeCount > 0) {
|
|
2240
|
+
log?.info(`releasing ${this.laneLedger.activeCount} stale lane(s) on reconnect`);
|
|
2241
|
+
await this.laneLedger.releaseAll();
|
|
2242
|
+
}
|
|
2243
|
+
// Re-probe the ledger each connection: a sticky downgrade from a
|
|
2244
|
+
// transient edge 404 during a rolling deploy must not outlive the
|
|
2245
|
+
// connection that observed it.
|
|
2246
|
+
if (this.laneLedger && this.ledgerDisabled) {
|
|
2247
|
+
log?.info('re-probing dispatch ledger after reconnect (was disabled)');
|
|
2248
|
+
this.ledgerDisabled = false;
|
|
2249
|
+
}
|
|
2021
2250
|
const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
|
|
2022
2251
|
try {
|
|
2023
2252
|
const count = await fetchAllChats(client, config.org_id, this.chatInfoMap);
|
|
@@ -2097,6 +2326,13 @@ export class ParallAgentGateway {
|
|
|
2097
2326
|
}
|
|
2098
2327
|
if (this.heartbeatTimer)
|
|
2099
2328
|
clearInterval(this.heartbeatTimer);
|
|
2329
|
+
// Completed turns already released their lanes; whatever is left belongs
|
|
2330
|
+
// to interrupted work — hand the members back so the replacement pod
|
|
2331
|
+
// re-claims immediately instead of waiting out the lease.
|
|
2332
|
+
if (this.laneLedger && this.laneLedger.activeCount > 0) {
|
|
2333
|
+
this.opts.log?.info(`releasing ${this.laneLedger.activeCount} lane(s) on shutdown`);
|
|
2334
|
+
await this.laneLedger.releaseAll();
|
|
2335
|
+
}
|
|
2100
2336
|
await this.opts.onBeforeDisconnect?.();
|
|
2101
2337
|
this.opts.ws.disconnect();
|
|
2102
2338
|
this.opts.log?.info(`disconnected`);
|