@parall/agent-core 1.18.1 → 1.19.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/gateway-base.d.ts +7 -0
- package/dist/gateway-base.d.ts.map +1 -1
- package/dist/gateway-base.js +167 -13
- package/package.json +2 -2
- package/src/gateway-base.ts +175 -14
package/dist/gateway-base.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type ParallGatewayOptions = {
|
|
|
18
18
|
dispatchAdapter: DispatchAdapter;
|
|
19
19
|
log?: GatewayLogger;
|
|
20
20
|
coldStartWindowMs?: number;
|
|
21
|
+
shutdownDeadlineMs?: number;
|
|
21
22
|
stepIdFilePathForSession?: (sessionKey: string) => string | undefined;
|
|
22
23
|
onConfigUpdate?: (data: AgentConfigUpdateData) => Promise<void> | void;
|
|
23
24
|
onSessionReady?: (state: {
|
|
@@ -27,6 +28,7 @@ export type ParallGatewayOptions = {
|
|
|
27
28
|
}) => Promise<void> | void;
|
|
28
29
|
onBeforeDisconnect?: () => Promise<void> | void;
|
|
29
30
|
};
|
|
31
|
+
export declare function parseShutdownDeadlineMs(raw: string | undefined): number | undefined;
|
|
30
32
|
export declare class ParallAgentGateway {
|
|
31
33
|
private readonly opts;
|
|
32
34
|
private readonly chatInfoMap;
|
|
@@ -41,8 +43,12 @@ export declare class ParallAgentGateway {
|
|
|
41
43
|
private hadSuccessfulHello;
|
|
42
44
|
private lastHeartbeatAt;
|
|
43
45
|
private draining;
|
|
46
|
+
private shuttingDown;
|
|
47
|
+
private inFlightDispatches;
|
|
48
|
+
private drainResolvers;
|
|
44
49
|
private readonly DISPATCHED_MESSAGES_CAP;
|
|
45
50
|
private readonly COLD_START_WINDOW_MS;
|
|
51
|
+
private readonly SHUTDOWN_DEADLINE_MS;
|
|
46
52
|
constructor(opts: ParallGatewayOptions);
|
|
47
53
|
run(abortSignal: AbortSignal): Promise<void>;
|
|
48
54
|
private tryClaimMessage;
|
|
@@ -65,6 +71,7 @@ export declare class ParallAgentGateway {
|
|
|
65
71
|
private handleTaskComment;
|
|
66
72
|
private catchUpFromDispatch;
|
|
67
73
|
private handleHello;
|
|
74
|
+
private waitForDrain;
|
|
68
75
|
private shutdown;
|
|
69
76
|
}
|
|
70
77
|
//# sourceMappingURL=gateway-base.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gateway-base.d.ts","sourceRoot":"","sources":["../src/gateway-base.ts"],"names":[],"mappings":"AAIA,OAAO,EAAuB,YAAY,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC1E,OAAO,KAAK,EACV,qBAAqB,EAUtB,MAAM,aAAa,CAAC;AAErB,OAAO,KAAK,EAEV,eAAe,EAGf,aAAa,EAEd,MAAM,uBAAuB,CAAC;AAoD/B,MAAM,MAAM,oBAAoB,GAAG;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,YAAY,CAAC;IACrB,EAAE,EAAE,QAAQ,CAAC;IACb,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,MAAM,EAAE;QACN,UAAU,EAAE,MAAM,CAAC;QACnB,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;IACF,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,eAAe,EAAE,eAAe,CAAC;IACjC,GAAG,CAAC,EAAE,aAAa,CAAC;IACpB,iBAAiB,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"gateway-base.d.ts","sourceRoot":"","sources":["../src/gateway-base.ts"],"names":[],"mappings":"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"}
|
package/dist/gateway-base.js
CHANGED
|
@@ -6,6 +6,22 @@ import { MENTION_ALL_USER_ID } from "@parall/sdk";
|
|
|
6
6
|
import { buildEventBody, buildForkResultPrefix } from "./event-format.js";
|
|
7
7
|
import { routeTrigger } from "./routing.js";
|
|
8
8
|
import { clearDispatchMessageId, clearDispatchNoReply, clearSessionMessageId, setDispatchMessageId, setDispatchNoReply, setSessionChatId, setSessionMessageId, } from "./session-state.js";
|
|
9
|
+
// Parse PRLL_SHUTDOWN_DEADLINE_MS (or any string env value) into a positive
|
|
10
|
+
// integer milliseconds value, or undefined if unset/invalid. Runtimes pass
|
|
11
|
+
// the result into ParallGatewayOptions.shutdownDeadlineMs; leaving it
|
|
12
|
+
// undefined falls back to the gateway default (60s). Operators can raise it
|
|
13
|
+
// when long-running tools need more time to drain — the K8s
|
|
14
|
+
// terminationGracePeriodSeconds (currently 90s) must remain larger than this
|
|
15
|
+
// plus the post-drain cleanup window (~15s), or the kubelet will SIGKILL
|
|
16
|
+
// before cleanup finishes.
|
|
17
|
+
export function parseShutdownDeadlineMs(raw) {
|
|
18
|
+
if (!raw)
|
|
19
|
+
return undefined;
|
|
20
|
+
const n = Number(raw);
|
|
21
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
22
|
+
return undefined;
|
|
23
|
+
return Math.floor(n);
|
|
24
|
+
}
|
|
9
25
|
function resolveStepTarget(event) {
|
|
10
26
|
if (event.type === "task" || event.targetId.startsWith("tsk_")) {
|
|
11
27
|
return { target_type: "task", target_id: event.targetId };
|
|
@@ -51,11 +67,23 @@ export class ParallAgentGateway {
|
|
|
51
67
|
hadSuccessfulHello = false;
|
|
52
68
|
lastHeartbeatAt = Date.now();
|
|
53
69
|
draining = false;
|
|
70
|
+
// Graceful shutdown state. When SIGTERM / abort fires, `shuttingDown` flips
|
|
71
|
+
// to true so no new dispatches start, and `inFlightDispatches` counts runs
|
|
72
|
+
// still in progress. `shutdown()` awaits drain up to SHUTDOWN_DEADLINE_MS
|
|
73
|
+
// before tearing down the WS; see handleTermination caller.
|
|
74
|
+
shuttingDown = false;
|
|
75
|
+
inFlightDispatches = 0;
|
|
76
|
+
drainResolvers = [];
|
|
54
77
|
DISPATCHED_MESSAGES_CAP = 5000;
|
|
55
78
|
COLD_START_WINDOW_MS;
|
|
79
|
+
// SHUTDOWN_DEADLINE_MS is read by waitForDrain via the configured value
|
|
80
|
+
// below — kept as instance state so per-runtime configs can override it
|
|
81
|
+
// (see parseShutdownDeadlineMs and runtime entrypoints).
|
|
82
|
+
SHUTDOWN_DEADLINE_MS;
|
|
56
83
|
constructor(opts) {
|
|
57
84
|
this.opts = opts;
|
|
58
85
|
this.COLD_START_WINDOW_MS = opts.coldStartWindowMs ?? 5 * 60_000;
|
|
86
|
+
this.SHUTDOWN_DEADLINE_MS = opts.shutdownDeadlineMs ?? 60_000;
|
|
59
87
|
}
|
|
60
88
|
async run(abortSignal) {
|
|
61
89
|
const { ws, log } = this.opts;
|
|
@@ -329,13 +357,28 @@ export class ParallAgentGateway {
|
|
|
329
357
|
await this.createInputStep(event);
|
|
330
358
|
}
|
|
331
359
|
}
|
|
360
|
+
// Returns true if the dispatch actually ran; false if skipped because we
|
|
361
|
+
// are shutting down. Callers MUST treat `false` as "not dispatched" and
|
|
362
|
+
// skip the server-side ack so the event stays in the dispatch queue for
|
|
363
|
+
// catch-up on the replacement pod — otherwise we silently drop work.
|
|
332
364
|
async runDispatch(event, sessionKey, bodyForAgent, earlierEvents = []) {
|
|
365
|
+
// Graceful shutdown: once SIGTERM has fired we stop accepting new work.
|
|
366
|
+
// In-flight dispatches that started before the flag flipped keep running
|
|
367
|
+
// and are awaited by shutdown() up to SHUTDOWN_DEADLINE_MS.
|
|
368
|
+
if (this.shuttingDown) {
|
|
369
|
+
this.opts.log?.info(`parall[${this.opts.accountId}]: skipping dispatch for ${event.messageId} (shutting down) — leaving unacked for catch-up on replacement pod`);
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
333
372
|
setSessionChatId(sessionKey, event.targetId);
|
|
334
373
|
setSessionMessageId(sessionKey, event.messageId);
|
|
335
374
|
setDispatchMessageId(sessionKey, event.messageId);
|
|
336
375
|
setDispatchNoReply(sessionKey, event.noReply ?? false);
|
|
337
376
|
const dispatchContext = this.buildDispatchContext(event, sessionKey);
|
|
338
377
|
const stepIdFilePath = dispatchContext.stepIdFilePath;
|
|
378
|
+
// sync: no await between the shuttingDown check above and this increment
|
|
379
|
+
// — JS event loop is single-threaded, so shutdown() cannot interleave
|
|
380
|
+
// here and miss our in-flight count.
|
|
381
|
+
this.inFlightDispatches++;
|
|
339
382
|
try {
|
|
340
383
|
if (this.activeSessionId) {
|
|
341
384
|
try {
|
|
@@ -345,6 +388,13 @@ export class ParallAgentGateway {
|
|
|
345
388
|
this.opts.log?.warn(`parall[${this.opts.accountId}]: failed to set session active: ${String(err)}`);
|
|
346
389
|
}
|
|
347
390
|
}
|
|
391
|
+
// Persist input steps for "earlier events" (batched events that arrived
|
|
392
|
+
// while a dispatch was in flight) inside the in-flight window so a
|
|
393
|
+
// shutdown short-circuit BEFORE this point cannot leave orphan input
|
|
394
|
+
// steps that the replacement pod would duplicate on replay.
|
|
395
|
+
if (earlierEvents.length > 0) {
|
|
396
|
+
await this.createInputStepsForEarlierEvents(earlierEvents);
|
|
397
|
+
}
|
|
348
398
|
await this.createInputStep(event);
|
|
349
399
|
for await (const runtimeEvent of this.opts.dispatchAdapter.dispatch({
|
|
350
400
|
event,
|
|
@@ -378,20 +428,43 @@ export class ParallAgentGateway {
|
|
|
378
428
|
this.opts.log?.warn(`parall[${this.opts.accountId}]: failed to set session idle: ${String(err)}`);
|
|
379
429
|
}
|
|
380
430
|
}
|
|
431
|
+
this.inFlightDispatches--;
|
|
432
|
+
if (this.inFlightDispatches === 0 && this.drainResolvers.length > 0) {
|
|
433
|
+
const resolvers = this.drainResolvers.splice(0);
|
|
434
|
+
for (const resolve of resolvers)
|
|
435
|
+
resolve();
|
|
436
|
+
}
|
|
381
437
|
}
|
|
438
|
+
return true;
|
|
382
439
|
}
|
|
383
440
|
async runForkDrainLoop(fork) {
|
|
384
441
|
try {
|
|
385
442
|
while (fork.queue.length > 0) {
|
|
443
|
+
// Early shutdown guard — pure optimization to avoid wasted draining
|
|
444
|
+
// during the shutdown window. The structural correctness now lives in
|
|
445
|
+
// runDispatch: input AgentStep persistence happens behind its
|
|
446
|
+
// shuttingDown gate and inFlightDispatches counter, so a shutdown
|
|
447
|
+
// landing mid-batch cannot leave orphan steps that replay would
|
|
448
|
+
// duplicate.
|
|
449
|
+
if (this.shuttingDown) {
|
|
450
|
+
for (const item of fork.queue.splice(0))
|
|
451
|
+
item.resolve(false);
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
386
454
|
const items = fork.queue.splice(0);
|
|
387
455
|
const events = items.map((item) => item.event);
|
|
388
456
|
const last = events[events.length - 1];
|
|
389
457
|
const earlier = events.slice(0, -1);
|
|
390
458
|
try {
|
|
391
|
-
|
|
392
|
-
|
|
459
|
+
const dispatched = await this.runDispatch(last, fork.fork.sessionKey, buildEventBody(last), earlier);
|
|
460
|
+
if (!dispatched) {
|
|
461
|
+
// Shutdown short-circuit — resolve un-acked so the server requeues
|
|
462
|
+
// for the replacement pod and stop draining further items.
|
|
463
|
+
for (const item of items) {
|
|
464
|
+
item.resolve(false);
|
|
465
|
+
}
|
|
466
|
+
break;
|
|
393
467
|
}
|
|
394
|
-
await this.runDispatch(last, fork.fork.sessionKey, buildEventBody(last), earlier);
|
|
395
468
|
fork.processedEvents.push(...events);
|
|
396
469
|
for (const item of items) {
|
|
397
470
|
item.resolve(true);
|
|
@@ -453,8 +526,17 @@ export class ParallAgentGateway {
|
|
|
453
526
|
this.draining = true;
|
|
454
527
|
try {
|
|
455
528
|
while (this.dispatchState.mainBuffer.length > 0 || this.dispatchState.pendingForkResults.length > 0) {
|
|
529
|
+
// Early shutdown guard — same reasoning as runForkDrainLoop: stop
|
|
530
|
+
// before any input AgentSteps are persisted, leaving the buffer +
|
|
531
|
+
// pendingForkResults intact so dispatch catch-up on the replacement
|
|
532
|
+
// pod is the single source of truth for replay.
|
|
533
|
+
if (this.shuttingDown) {
|
|
534
|
+
this.opts.log?.info(`parall[${this.opts.accountId}]: drainMainBuffer halted (shutting down) — ${this.dispatchState.mainBuffer.length} buffered, ${this.dispatchState.pendingForkResults.length} pending fork results left for catch-up`);
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
456
537
|
if (this.dispatchState.mainBuffer.length === 0 && this.dispatchState.pendingForkResults.length > 0) {
|
|
457
|
-
const
|
|
538
|
+
const pending = this.dispatchState.pendingForkResults.splice(0);
|
|
539
|
+
const forkPrefix = buildForkResultPrefix(pending);
|
|
458
540
|
const syntheticEvent = {
|
|
459
541
|
type: "message",
|
|
460
542
|
targetId: "_orchestrator",
|
|
@@ -465,7 +547,14 @@ export class ParallAgentGateway {
|
|
|
465
547
|
body: "[Orchestrator: fork session(s) completed — review results above]",
|
|
466
548
|
};
|
|
467
549
|
this.dispatchState.mainCurrentTargetId = undefined;
|
|
468
|
-
await this.runDispatch(syntheticEvent, this.opts.runtimeKey, forkPrefix + buildEventBody(syntheticEvent));
|
|
550
|
+
const dispatched = await this.runDispatch(syntheticEvent, this.opts.runtimeKey, forkPrefix + buildEventBody(syntheticEvent));
|
|
551
|
+
if (!dispatched) {
|
|
552
|
+
// Shutdown: put the fork results back at the head so the synthetic
|
|
553
|
+
// event is regenerated on the replacement pod. Synthetic events are
|
|
554
|
+
// not ack'd, so this is the only state we need to preserve.
|
|
555
|
+
this.dispatchState.pendingForkResults.unshift(...pending);
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
469
558
|
continue;
|
|
470
559
|
}
|
|
471
560
|
const targetId = this.dispatchState.mainBuffer[0].targetId;
|
|
@@ -475,12 +564,20 @@ export class ParallAgentGateway {
|
|
|
475
564
|
}
|
|
476
565
|
const event = events[events.length - 1];
|
|
477
566
|
const earlier = events.slice(0, -1);
|
|
478
|
-
const
|
|
567
|
+
const pendingFork = this.dispatchState.pendingForkResults.splice(0);
|
|
568
|
+
const forkPrefix = buildForkResultPrefix(pendingFork);
|
|
479
569
|
this.dispatchState.mainCurrentTargetId = event.targetId;
|
|
480
|
-
|
|
481
|
-
|
|
570
|
+
// Earlier-event input steps are persisted inside runDispatch (behind
|
|
571
|
+
// its shutdown gate) so a shutdown short-circuit cannot leave orphans.
|
|
572
|
+
const dispatched = await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event), earlier);
|
|
573
|
+
if (!dispatched) {
|
|
574
|
+
// Shutdown: skip the ack so the server redelivers these buffered
|
|
575
|
+
// events to the replacement pod via dispatch catch-up. Put both the
|
|
576
|
+
// buffered events and the fork results back so nothing is lost.
|
|
577
|
+
this.dispatchState.mainBuffer.unshift(...events);
|
|
578
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
579
|
+
break;
|
|
482
580
|
}
|
|
483
|
-
await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event), earlier);
|
|
484
581
|
for (const bufferedEvent of events) {
|
|
485
582
|
const sourceType = bufferedEvent.ackSourceType ?? (bufferedEvent.type === "task" ? "task_activity" : "message");
|
|
486
583
|
const sourceId = bufferedEvent.ackSourceId ?? bufferedEvent.messageId;
|
|
@@ -501,16 +598,23 @@ export class ParallAgentGateway {
|
|
|
501
598
|
const disposition = routeTrigger(event, this.dispatchState);
|
|
502
599
|
switch (disposition.action) {
|
|
503
600
|
case "main": {
|
|
504
|
-
const
|
|
601
|
+
const pendingFork = this.dispatchState.pendingForkResults.splice(0);
|
|
602
|
+
const forkPrefix = buildForkResultPrefix(pendingFork);
|
|
505
603
|
this.dispatchState.mainDispatching = true;
|
|
506
604
|
this.dispatchState.mainCurrentTargetId = event.targetId;
|
|
605
|
+
let dispatched = false;
|
|
507
606
|
try {
|
|
508
|
-
await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event));
|
|
607
|
+
dispatched = await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event));
|
|
608
|
+
if (!dispatched) {
|
|
609
|
+
// Shutdown short-circuit — restore the fork results so a future
|
|
610
|
+
// pod can replay them, and return false so handleMessage skips ack.
|
|
611
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
612
|
+
}
|
|
509
613
|
}
|
|
510
614
|
finally {
|
|
511
615
|
await this.drainMainBuffer();
|
|
512
616
|
}
|
|
513
|
-
return
|
|
617
|
+
return dispatched;
|
|
514
618
|
}
|
|
515
619
|
case "buffer-main":
|
|
516
620
|
this.dispatchState.mainBuffer.push(event);
|
|
@@ -620,6 +724,8 @@ export class ParallAgentGateway {
|
|
|
620
724
|
};
|
|
621
725
|
}
|
|
622
726
|
async handleMessage(data) {
|
|
727
|
+
if (this.shuttingDown)
|
|
728
|
+
return; // drain window — let server requeue via catch-up
|
|
623
729
|
const chatId = data.chat_id;
|
|
624
730
|
if (data.sender_id === this.opts.agentUserId)
|
|
625
731
|
return;
|
|
@@ -655,6 +761,8 @@ export class ParallAgentGateway {
|
|
|
655
761
|
}
|
|
656
762
|
}
|
|
657
763
|
async handleTaskAssignment(task, ackSourceId) {
|
|
764
|
+
if (this.shuttingDown)
|
|
765
|
+
return false; // drain window — let server requeue via catch-up
|
|
658
766
|
const dedupeKey = `${task.id}:${task.updated_at}`;
|
|
659
767
|
if (this.dispatchedTasks.has(dedupeKey)) {
|
|
660
768
|
this.opts.log?.info(`parall[${this.opts.accountId}]: skipping already-dispatched task ${task.identifier ?? task.id}`);
|
|
@@ -689,6 +797,8 @@ export class ParallAgentGateway {
|
|
|
689
797
|
return dispatched;
|
|
690
798
|
}
|
|
691
799
|
async handleTaskComment(commentId, taskId, actorId) {
|
|
800
|
+
if (this.shuttingDown)
|
|
801
|
+
return false; // drain window — let server requeue via catch-up
|
|
692
802
|
const dedupeKey = `comment:${commentId}`;
|
|
693
803
|
if (this.dispatchedTasks.has(dedupeKey))
|
|
694
804
|
return false;
|
|
@@ -766,6 +876,10 @@ export class ParallAgentGateway {
|
|
|
766
876
|
cursor,
|
|
767
877
|
});
|
|
768
878
|
for (const item of page.data ?? []) {
|
|
879
|
+
// Shutdown short-circuit: stop fetching/building work that runDispatch
|
|
880
|
+
// will only reject. Items remain unacked for the replacement pod.
|
|
881
|
+
if (this.shuttingDown)
|
|
882
|
+
break;
|
|
769
883
|
if (minAge > 0 && new Date(item.created_at).getTime() < minAge) {
|
|
770
884
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
|
|
771
885
|
skippedOld++;
|
|
@@ -854,7 +968,9 @@ export class ParallAgentGateway {
|
|
|
854
968
|
this.opts.log?.warn(`parall[${this.opts.accountId}]: catch-up dispatch ${item.id} (${item.event_type}) failed: ${String(err)}`);
|
|
855
969
|
}
|
|
856
970
|
}
|
|
857
|
-
|
|
971
|
+
// Stop paginating once shutdown begins — the inner loop already broke,
|
|
972
|
+
// and the next page would be wasted API work the replacement pod redoes.
|
|
973
|
+
cursor = !this.shuttingDown && page.has_more ? page.next_cursor : undefined;
|
|
858
974
|
} while (cursor);
|
|
859
975
|
if (processed > 0 || skippedOld > 0) {
|
|
860
976
|
this.opts.log?.info(`parall[${this.opts.accountId}]: dispatch catch-up: processed ${processed}, skipped ${skippedOld} old item(s)`);
|
|
@@ -917,7 +1033,45 @@ export class ParallAgentGateway {
|
|
|
917
1033
|
log?.error(`parall[${this.opts.accountId}]: failed to fetch chats: ${String(err)}`);
|
|
918
1034
|
}
|
|
919
1035
|
}
|
|
1036
|
+
// Resolves when in-flight dispatches hit 0 or the deadline elapses.
|
|
1037
|
+
// Deadline arming the resolver rather than rejecting keeps shutdown() linear
|
|
1038
|
+
// — callers don't need to try/catch.
|
|
1039
|
+
waitForDrain(deadlineMs) {
|
|
1040
|
+
if (this.inFlightDispatches === 0)
|
|
1041
|
+
return Promise.resolve();
|
|
1042
|
+
return new Promise((resolve) => {
|
|
1043
|
+
const timer = setTimeout(() => {
|
|
1044
|
+
// Remove this resolver so a late-completing dispatch doesn't call it.
|
|
1045
|
+
const idx = this.drainResolvers.indexOf(onDrain);
|
|
1046
|
+
if (idx >= 0)
|
|
1047
|
+
this.drainResolvers.splice(idx, 1);
|
|
1048
|
+
resolve();
|
|
1049
|
+
}, deadlineMs);
|
|
1050
|
+
const onDrain = () => {
|
|
1051
|
+
clearTimeout(timer);
|
|
1052
|
+
resolve();
|
|
1053
|
+
};
|
|
1054
|
+
this.drainResolvers.push(onDrain);
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
920
1057
|
async shutdown() {
|
|
1058
|
+
// Flip the flag first so any WS event that fires while we're draining
|
|
1059
|
+
// short-circuits at runDispatch instead of starting new work.
|
|
1060
|
+
this.shuttingDown = true;
|
|
1061
|
+
// Drain: wait for in-flight dispatches to finish up to the configured
|
|
1062
|
+
// deadline. If we time out, whatever is still running will be killed by
|
|
1063
|
+
// the process exit — that's acceptable because K8s will retry the event
|
|
1064
|
+
// via dispatch catch-up after the new pod starts.
|
|
1065
|
+
if (this.inFlightDispatches > 0) {
|
|
1066
|
+
this.opts.log?.info(`parall[${this.opts.accountId}]: draining ${this.inFlightDispatches} in-flight dispatch(es), deadline ${this.SHUTDOWN_DEADLINE_MS}ms`);
|
|
1067
|
+
await this.waitForDrain(this.SHUTDOWN_DEADLINE_MS);
|
|
1068
|
+
if (this.inFlightDispatches > 0) {
|
|
1069
|
+
this.opts.log?.warn(`parall[${this.opts.accountId}]: drain deadline hit; ${this.inFlightDispatches} dispatch(es) still running — they will be killed by process exit`);
|
|
1070
|
+
}
|
|
1071
|
+
else {
|
|
1072
|
+
this.opts.log?.info(`parall[${this.opts.accountId}]: drain complete`);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
921
1075
|
if (this.heartbeatTimer)
|
|
922
1076
|
clearInterval(this.heartbeatTimer);
|
|
923
1077
|
for (const [, dispatch] of this.activeDispatches) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parall/agent-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.0",
|
|
4
4
|
"description": "Shared agent runtime orchestration helpers for Parall",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"src"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@parall/sdk": "1.
|
|
25
|
+
"@parall/sdk": "1.19.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^22.0.0",
|
package/src/gateway-base.ts
CHANGED
|
@@ -92,12 +92,31 @@ export type ParallGatewayOptions = {
|
|
|
92
92
|
dispatchAdapter: DispatchAdapter;
|
|
93
93
|
log?: GatewayLogger;
|
|
94
94
|
coldStartWindowMs?: number;
|
|
95
|
+
// Maximum time to wait for in-flight dispatches to finish after SIGTERM /
|
|
96
|
+
// abort before forcing WS disconnect. Pod termination grace period should
|
|
97
|
+
// be at least this + a few seconds for the remaining cleanup work.
|
|
98
|
+
shutdownDeadlineMs?: number;
|
|
95
99
|
stepIdFilePathForSession?: (sessionKey: string) => string | undefined;
|
|
96
100
|
onConfigUpdate?: (data: AgentConfigUpdateData) => Promise<void> | void;
|
|
97
101
|
onSessionReady?: (state: { activeSessionId?: string; ws: ParallWs; runtimeKey: string }) => Promise<void> | void;
|
|
98
102
|
onBeforeDisconnect?: () => Promise<void> | void;
|
|
99
103
|
};
|
|
100
104
|
|
|
105
|
+
// Parse PRLL_SHUTDOWN_DEADLINE_MS (or any string env value) into a positive
|
|
106
|
+
// integer milliseconds value, or undefined if unset/invalid. Runtimes pass
|
|
107
|
+
// the result into ParallGatewayOptions.shutdownDeadlineMs; leaving it
|
|
108
|
+
// undefined falls back to the gateway default (60s). Operators can raise it
|
|
109
|
+
// when long-running tools need more time to drain — the K8s
|
|
110
|
+
// terminationGracePeriodSeconds (currently 90s) must remain larger than this
|
|
111
|
+
// plus the post-drain cleanup window (~15s), or the kubelet will SIGKILL
|
|
112
|
+
// before cleanup finishes.
|
|
113
|
+
export function parseShutdownDeadlineMs(raw: string | undefined): number | undefined {
|
|
114
|
+
if (!raw) return undefined;
|
|
115
|
+
const n = Number(raw);
|
|
116
|
+
if (!Number.isFinite(n) || n <= 0) return undefined;
|
|
117
|
+
return Math.floor(n);
|
|
118
|
+
}
|
|
119
|
+
|
|
101
120
|
function resolveStepTarget(event: ParallEvent): { target_type: string; target_id?: string } {
|
|
102
121
|
if (event.type === "task" || event.targetId.startsWith("tsk_")) {
|
|
103
122
|
return { target_type: "task", target_id: event.targetId };
|
|
@@ -150,11 +169,24 @@ export class ParallAgentGateway {
|
|
|
150
169
|
private lastHeartbeatAt = Date.now();
|
|
151
170
|
private draining = false;
|
|
152
171
|
|
|
172
|
+
// Graceful shutdown state. When SIGTERM / abort fires, `shuttingDown` flips
|
|
173
|
+
// to true so no new dispatches start, and `inFlightDispatches` counts runs
|
|
174
|
+
// still in progress. `shutdown()` awaits drain up to SHUTDOWN_DEADLINE_MS
|
|
175
|
+
// before tearing down the WS; see handleTermination caller.
|
|
176
|
+
private shuttingDown = false;
|
|
177
|
+
private inFlightDispatches = 0;
|
|
178
|
+
private drainResolvers: Array<() => void> = [];
|
|
179
|
+
|
|
153
180
|
private readonly DISPATCHED_MESSAGES_CAP = 5000;
|
|
154
181
|
private readonly COLD_START_WINDOW_MS: number;
|
|
182
|
+
// SHUTDOWN_DEADLINE_MS is read by waitForDrain via the configured value
|
|
183
|
+
// below — kept as instance state so per-runtime configs can override it
|
|
184
|
+
// (see parseShutdownDeadlineMs and runtime entrypoints).
|
|
185
|
+
private readonly SHUTDOWN_DEADLINE_MS: number;
|
|
155
186
|
|
|
156
187
|
constructor(private readonly opts: ParallGatewayOptions) {
|
|
157
188
|
this.COLD_START_WINDOW_MS = opts.coldStartWindowMs ?? 5 * 60_000;
|
|
189
|
+
this.SHUTDOWN_DEADLINE_MS = opts.shutdownDeadlineMs ?? 60_000;
|
|
158
190
|
}
|
|
159
191
|
|
|
160
192
|
async run(abortSignal: AbortSignal): Promise<void> {
|
|
@@ -435,12 +467,26 @@ export class ParallAgentGateway {
|
|
|
435
467
|
}
|
|
436
468
|
}
|
|
437
469
|
|
|
470
|
+
// Returns true if the dispatch actually ran; false if skipped because we
|
|
471
|
+
// are shutting down. Callers MUST treat `false` as "not dispatched" and
|
|
472
|
+
// skip the server-side ack so the event stays in the dispatch queue for
|
|
473
|
+
// catch-up on the replacement pod — otherwise we silently drop work.
|
|
438
474
|
private async runDispatch(
|
|
439
475
|
event: ParallEvent,
|
|
440
476
|
sessionKey: string,
|
|
441
477
|
bodyForAgent: string,
|
|
442
478
|
earlierEvents: ParallEvent[] = [],
|
|
443
|
-
) {
|
|
479
|
+
): Promise<boolean> {
|
|
480
|
+
// Graceful shutdown: once SIGTERM has fired we stop accepting new work.
|
|
481
|
+
// In-flight dispatches that started before the flag flipped keep running
|
|
482
|
+
// and are awaited by shutdown() up to SHUTDOWN_DEADLINE_MS.
|
|
483
|
+
if (this.shuttingDown) {
|
|
484
|
+
this.opts.log?.info(
|
|
485
|
+
`parall[${this.opts.accountId}]: skipping dispatch for ${event.messageId} (shutting down) — leaving unacked for catch-up on replacement pod`,
|
|
486
|
+
);
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
|
|
444
490
|
setSessionChatId(sessionKey, event.targetId);
|
|
445
491
|
setSessionMessageId(sessionKey, event.messageId);
|
|
446
492
|
setDispatchMessageId(sessionKey, event.messageId);
|
|
@@ -449,6 +495,10 @@ export class ParallAgentGateway {
|
|
|
449
495
|
const dispatchContext = this.buildDispatchContext(event, sessionKey);
|
|
450
496
|
const stepIdFilePath = dispatchContext.stepIdFilePath;
|
|
451
497
|
|
|
498
|
+
// sync: no await between the shuttingDown check above and this increment
|
|
499
|
+
// — JS event loop is single-threaded, so shutdown() cannot interleave
|
|
500
|
+
// here and miss our in-flight count.
|
|
501
|
+
this.inFlightDispatches++;
|
|
452
502
|
try {
|
|
453
503
|
if (this.activeSessionId) {
|
|
454
504
|
try {
|
|
@@ -458,6 +508,13 @@ export class ParallAgentGateway {
|
|
|
458
508
|
}
|
|
459
509
|
}
|
|
460
510
|
|
|
511
|
+
// Persist input steps for "earlier events" (batched events that arrived
|
|
512
|
+
// while a dispatch was in flight) inside the in-flight window so a
|
|
513
|
+
// shutdown short-circuit BEFORE this point cannot leave orphan input
|
|
514
|
+
// steps that the replacement pod would duplicate on replay.
|
|
515
|
+
if (earlierEvents.length > 0) {
|
|
516
|
+
await this.createInputStepsForEarlierEvents(earlierEvents);
|
|
517
|
+
}
|
|
461
518
|
await this.createInputStep(event);
|
|
462
519
|
|
|
463
520
|
for await (const runtimeEvent of this.opts.dispatchAdapter.dispatch({
|
|
@@ -489,21 +546,42 @@ export class ParallAgentGateway {
|
|
|
489
546
|
this.opts.log?.warn(`parall[${this.opts.accountId}]: failed to set session idle: ${String(err)}`);
|
|
490
547
|
}
|
|
491
548
|
}
|
|
549
|
+
this.inFlightDispatches--;
|
|
550
|
+
if (this.inFlightDispatches === 0 && this.drainResolvers.length > 0) {
|
|
551
|
+
const resolvers = this.drainResolvers.splice(0);
|
|
552
|
+
for (const resolve of resolvers) resolve();
|
|
553
|
+
}
|
|
492
554
|
}
|
|
555
|
+
return true;
|
|
493
556
|
}
|
|
494
557
|
|
|
495
558
|
private async runForkDrainLoop(fork: ActiveForkState) {
|
|
496
559
|
try {
|
|
497
560
|
while (fork.queue.length > 0) {
|
|
561
|
+
// Early shutdown guard — pure optimization to avoid wasted draining
|
|
562
|
+
// during the shutdown window. The structural correctness now lives in
|
|
563
|
+
// runDispatch: input AgentStep persistence happens behind its
|
|
564
|
+
// shuttingDown gate and inFlightDispatches counter, so a shutdown
|
|
565
|
+
// landing mid-batch cannot leave orphan steps that replay would
|
|
566
|
+
// duplicate.
|
|
567
|
+
if (this.shuttingDown) {
|
|
568
|
+
for (const item of fork.queue.splice(0)) item.resolve(false);
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
498
571
|
const items = fork.queue.splice(0);
|
|
499
572
|
const events = items.map((item) => item.event);
|
|
500
573
|
const last = events[events.length - 1];
|
|
501
574
|
const earlier = events.slice(0, -1);
|
|
502
575
|
try {
|
|
503
|
-
|
|
504
|
-
|
|
576
|
+
const dispatched = await this.runDispatch(last, fork.fork.sessionKey, buildEventBody(last), earlier);
|
|
577
|
+
if (!dispatched) {
|
|
578
|
+
// Shutdown short-circuit — resolve un-acked so the server requeues
|
|
579
|
+
// for the replacement pod and stop draining further items.
|
|
580
|
+
for (const item of items) {
|
|
581
|
+
item.resolve(false);
|
|
582
|
+
}
|
|
583
|
+
break;
|
|
505
584
|
}
|
|
506
|
-
await this.runDispatch(last, fork.fork.sessionKey, buildEventBody(last), earlier);
|
|
507
585
|
fork.processedEvents.push(...events);
|
|
508
586
|
for (const item of items) {
|
|
509
587
|
item.resolve(true);
|
|
@@ -564,8 +642,19 @@ export class ParallAgentGateway {
|
|
|
564
642
|
this.draining = true;
|
|
565
643
|
try {
|
|
566
644
|
while (this.dispatchState.mainBuffer.length > 0 || this.dispatchState.pendingForkResults.length > 0) {
|
|
645
|
+
// Early shutdown guard — same reasoning as runForkDrainLoop: stop
|
|
646
|
+
// before any input AgentSteps are persisted, leaving the buffer +
|
|
647
|
+
// pendingForkResults intact so dispatch catch-up on the replacement
|
|
648
|
+
// pod is the single source of truth for replay.
|
|
649
|
+
if (this.shuttingDown) {
|
|
650
|
+
this.opts.log?.info(
|
|
651
|
+
`parall[${this.opts.accountId}]: drainMainBuffer halted (shutting down) — ${this.dispatchState.mainBuffer.length} buffered, ${this.dispatchState.pendingForkResults.length} pending fork results left for catch-up`,
|
|
652
|
+
);
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
567
655
|
if (this.dispatchState.mainBuffer.length === 0 && this.dispatchState.pendingForkResults.length > 0) {
|
|
568
|
-
const
|
|
656
|
+
const pending = this.dispatchState.pendingForkResults.splice(0);
|
|
657
|
+
const forkPrefix = buildForkResultPrefix(pending);
|
|
569
658
|
const syntheticEvent: ParallEvent = {
|
|
570
659
|
type: "message",
|
|
571
660
|
targetId: "_orchestrator",
|
|
@@ -576,7 +665,14 @@ export class ParallAgentGateway {
|
|
|
576
665
|
body: "[Orchestrator: fork session(s) completed — review results above]",
|
|
577
666
|
};
|
|
578
667
|
this.dispatchState.mainCurrentTargetId = undefined;
|
|
579
|
-
await this.runDispatch(syntheticEvent, this.opts.runtimeKey, forkPrefix + buildEventBody(syntheticEvent));
|
|
668
|
+
const dispatched = await this.runDispatch(syntheticEvent, this.opts.runtimeKey, forkPrefix + buildEventBody(syntheticEvent));
|
|
669
|
+
if (!dispatched) {
|
|
670
|
+
// Shutdown: put the fork results back at the head so the synthetic
|
|
671
|
+
// event is regenerated on the replacement pod. Synthetic events are
|
|
672
|
+
// not ack'd, so this is the only state we need to preserve.
|
|
673
|
+
this.dispatchState.pendingForkResults.unshift(...pending);
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
580
676
|
continue;
|
|
581
677
|
}
|
|
582
678
|
|
|
@@ -588,12 +684,20 @@ export class ParallAgentGateway {
|
|
|
588
684
|
|
|
589
685
|
const event = events[events.length - 1];
|
|
590
686
|
const earlier = events.slice(0, -1);
|
|
591
|
-
const
|
|
687
|
+
const pendingFork = this.dispatchState.pendingForkResults.splice(0);
|
|
688
|
+
const forkPrefix = buildForkResultPrefix(pendingFork);
|
|
592
689
|
this.dispatchState.mainCurrentTargetId = event.targetId;
|
|
593
|
-
|
|
594
|
-
|
|
690
|
+
// Earlier-event input steps are persisted inside runDispatch (behind
|
|
691
|
+
// its shutdown gate) so a shutdown short-circuit cannot leave orphans.
|
|
692
|
+
const dispatched = await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event), earlier);
|
|
693
|
+
if (!dispatched) {
|
|
694
|
+
// Shutdown: skip the ack so the server redelivers these buffered
|
|
695
|
+
// events to the replacement pod via dispatch catch-up. Put both the
|
|
696
|
+
// buffered events and the fork results back so nothing is lost.
|
|
697
|
+
this.dispatchState.mainBuffer.unshift(...events);
|
|
698
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
699
|
+
break;
|
|
595
700
|
}
|
|
596
|
-
await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event), earlier);
|
|
597
701
|
for (const bufferedEvent of events) {
|
|
598
702
|
const sourceType = bufferedEvent.ackSourceType ?? (bufferedEvent.type === "task" ? "task_activity" : "message");
|
|
599
703
|
const sourceId = bufferedEvent.ackSourceId ?? bufferedEvent.messageId;
|
|
@@ -615,15 +719,22 @@ export class ParallAgentGateway {
|
|
|
615
719
|
|
|
616
720
|
switch (disposition.action) {
|
|
617
721
|
case "main": {
|
|
618
|
-
const
|
|
722
|
+
const pendingFork = this.dispatchState.pendingForkResults.splice(0);
|
|
723
|
+
const forkPrefix = buildForkResultPrefix(pendingFork);
|
|
619
724
|
this.dispatchState.mainDispatching = true;
|
|
620
725
|
this.dispatchState.mainCurrentTargetId = event.targetId;
|
|
726
|
+
let dispatched = false;
|
|
621
727
|
try {
|
|
622
|
-
await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event));
|
|
728
|
+
dispatched = await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event));
|
|
729
|
+
if (!dispatched) {
|
|
730
|
+
// Shutdown short-circuit — restore the fork results so a future
|
|
731
|
+
// pod can replay them, and return false so handleMessage skips ack.
|
|
732
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
733
|
+
}
|
|
623
734
|
} finally {
|
|
624
735
|
await this.drainMainBuffer();
|
|
625
736
|
}
|
|
626
|
-
return
|
|
737
|
+
return dispatched;
|
|
627
738
|
}
|
|
628
739
|
|
|
629
740
|
case "buffer-main":
|
|
@@ -749,6 +860,7 @@ export class ParallAgentGateway {
|
|
|
749
860
|
}
|
|
750
861
|
|
|
751
862
|
private async handleMessage(data: MessageNewData) {
|
|
863
|
+
if (this.shuttingDown) return; // drain window — let server requeue via catch-up
|
|
752
864
|
const chatId = data.chat_id;
|
|
753
865
|
if (data.sender_id === this.opts.agentUserId) return;
|
|
754
866
|
if (data.message_type !== "text") return;
|
|
@@ -779,6 +891,7 @@ export class ParallAgentGateway {
|
|
|
779
891
|
}
|
|
780
892
|
|
|
781
893
|
private async handleTaskAssignment(task: Task, ackSourceId?: string): Promise<boolean> {
|
|
894
|
+
if (this.shuttingDown) return false; // drain window — let server requeue via catch-up
|
|
782
895
|
const dedupeKey = `${task.id}:${task.updated_at}`;
|
|
783
896
|
if (this.dispatchedTasks.has(dedupeKey)) {
|
|
784
897
|
this.opts.log?.info(`parall[${this.opts.accountId}]: skipping already-dispatched task ${task.identifier ?? task.id}`);
|
|
@@ -814,6 +927,7 @@ export class ParallAgentGateway {
|
|
|
814
927
|
}
|
|
815
928
|
|
|
816
929
|
private async handleTaskComment(commentId: string, taskId: string, actorId: string | null): Promise<boolean> {
|
|
930
|
+
if (this.shuttingDown) return false; // drain window — let server requeue via catch-up
|
|
817
931
|
const dedupeKey = `comment:${commentId}`;
|
|
818
932
|
if (this.dispatchedTasks.has(dedupeKey)) return false;
|
|
819
933
|
this.dispatchedTasks.add(dedupeKey);
|
|
@@ -895,6 +1009,9 @@ export class ParallAgentGateway {
|
|
|
895
1009
|
});
|
|
896
1010
|
|
|
897
1011
|
for (const item of page.data ?? []) {
|
|
1012
|
+
// Shutdown short-circuit: stop fetching/building work that runDispatch
|
|
1013
|
+
// will only reject. Items remain unacked for the replacement pod.
|
|
1014
|
+
if (this.shuttingDown) break;
|
|
898
1015
|
if (minAge > 0 && new Date(item.created_at).getTime() < minAge) {
|
|
899
1016
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
|
|
900
1017
|
skippedOld++;
|
|
@@ -976,7 +1093,9 @@ export class ParallAgentGateway {
|
|
|
976
1093
|
this.opts.log?.warn(`parall[${this.opts.accountId}]: catch-up dispatch ${item.id} (${item.event_type}) failed: ${String(err)}`);
|
|
977
1094
|
}
|
|
978
1095
|
}
|
|
979
|
-
|
|
1096
|
+
// Stop paginating once shutdown begins — the inner loop already broke,
|
|
1097
|
+
// and the next page would be wasted API work the replacement pod redoes.
|
|
1098
|
+
cursor = !this.shuttingDown && page.has_more ? page.next_cursor : undefined;
|
|
980
1099
|
} while (cursor);
|
|
981
1100
|
|
|
982
1101
|
if (processed > 0 || skippedOld > 0) {
|
|
@@ -1042,7 +1161,49 @@ export class ParallAgentGateway {
|
|
|
1042
1161
|
}
|
|
1043
1162
|
}
|
|
1044
1163
|
|
|
1164
|
+
// Resolves when in-flight dispatches hit 0 or the deadline elapses.
|
|
1165
|
+
// Deadline arming the resolver rather than rejecting keeps shutdown() linear
|
|
1166
|
+
// — callers don't need to try/catch.
|
|
1167
|
+
private waitForDrain(deadlineMs: number): Promise<void> {
|
|
1168
|
+
if (this.inFlightDispatches === 0) return Promise.resolve();
|
|
1169
|
+
return new Promise<void>((resolve) => {
|
|
1170
|
+
const timer = setTimeout(() => {
|
|
1171
|
+
// Remove this resolver so a late-completing dispatch doesn't call it.
|
|
1172
|
+
const idx = this.drainResolvers.indexOf(onDrain);
|
|
1173
|
+
if (idx >= 0) this.drainResolvers.splice(idx, 1);
|
|
1174
|
+
resolve();
|
|
1175
|
+
}, deadlineMs);
|
|
1176
|
+
const onDrain = () => {
|
|
1177
|
+
clearTimeout(timer);
|
|
1178
|
+
resolve();
|
|
1179
|
+
};
|
|
1180
|
+
this.drainResolvers.push(onDrain);
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1045
1184
|
private async shutdown() {
|
|
1185
|
+
// Flip the flag first so any WS event that fires while we're draining
|
|
1186
|
+
// short-circuits at runDispatch instead of starting new work.
|
|
1187
|
+
this.shuttingDown = true;
|
|
1188
|
+
|
|
1189
|
+
// Drain: wait for in-flight dispatches to finish up to the configured
|
|
1190
|
+
// deadline. If we time out, whatever is still running will be killed by
|
|
1191
|
+
// the process exit — that's acceptable because K8s will retry the event
|
|
1192
|
+
// via dispatch catch-up after the new pod starts.
|
|
1193
|
+
if (this.inFlightDispatches > 0) {
|
|
1194
|
+
this.opts.log?.info(
|
|
1195
|
+
`parall[${this.opts.accountId}]: draining ${this.inFlightDispatches} in-flight dispatch(es), deadline ${this.SHUTDOWN_DEADLINE_MS}ms`,
|
|
1196
|
+
);
|
|
1197
|
+
await this.waitForDrain(this.SHUTDOWN_DEADLINE_MS);
|
|
1198
|
+
if (this.inFlightDispatches > 0) {
|
|
1199
|
+
this.opts.log?.warn(
|
|
1200
|
+
`parall[${this.opts.accountId}]: drain deadline hit; ${this.inFlightDispatches} dispatch(es) still running — they will be killed by process exit`,
|
|
1201
|
+
);
|
|
1202
|
+
} else {
|
|
1203
|
+
this.opts.log?.info(`parall[${this.opts.accountId}]: drain complete`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1046
1207
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
1047
1208
|
for (const [, dispatch] of this.activeDispatches) {
|
|
1048
1209
|
clearInterval(dispatch.typingTimer);
|