@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.
@@ -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;IAC3B,wBAAwB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;IACtE,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,qBAAqB,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACvE,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,eAAe,CAAC,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,QAAQ,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACjH,kBAAkB,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CACjD,CAAC;AAkCF,qBAAa,kBAAkB;IAuBjB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAtBjC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA+B;IAC3D,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAqC;IACtE,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAqB;IACrD,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAqB;IACxD,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAsC;IACjE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAK5B;IAEF,OAAO,CAAC,SAAS,CAAM;IACvB,OAAO,CAAC,eAAe,CAAqB;IAC5C,OAAO,CAAC,cAAc,CAA+C;IACrE,OAAO,CAAC,kBAAkB,CAAS;IACnC,OAAO,CAAC,eAAe,CAAc;IACrC,OAAO,CAAC,QAAQ,CAAS;IAEzB,OAAO,CAAC,QAAQ,CAAC,uBAAuB,CAAQ;IAChD,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAS;gBAEjB,IAAI,EAAE,oBAAoB;IAIjD,GAAG,CAAC,WAAW,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IA2FlD,OAAO,CAAC,eAAe;IAavB,OAAO,CAAC,WAAW;IAcnB,OAAO,CAAC,UAAU;IAWlB,OAAO,CAAC,oBAAoB;YAmBd,eAAe;YAqBf,iBAAiB;IAsF/B,OAAO,CAAC,eAAe;IASvB,OAAO,CAAC,eAAe;YAQT,gCAAgC;YAMhC,WAAW;YAyDX,gBAAgB;YAmEhB,eAAe;YAmDf,kBAAkB;YAoElB,kBAAkB;YAkBlB,4BAA4B;YAoD5B,aAAa;YA8Bb,oBAAoB;YAmCpB,iBAAiB;YAqEjB,mBAAmB;YAsGnB,WAAW;YA0DX,QAAQ;CAsBvB"}
1
+ {"version":3,"file":"gateway-base.d.ts","sourceRoot":"","sources":["../src/gateway-base.ts"],"names":[],"mappings":"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"}
@@ -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
- if (earlier.length > 0) {
392
- await this.createInputStepsForEarlierEvents(earlier);
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 forkPrefix = buildForkResultPrefix(this.dispatchState.pendingForkResults.splice(0));
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 forkPrefix = buildForkResultPrefix(this.dispatchState.pendingForkResults.splice(0));
567
+ const pendingFork = this.dispatchState.pendingForkResults.splice(0);
568
+ const forkPrefix = buildForkResultPrefix(pendingFork);
479
569
  this.dispatchState.mainCurrentTargetId = event.targetId;
480
- if (earlier.length > 0) {
481
- await this.createInputStepsForEarlierEvents(earlier);
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 forkPrefix = buildForkResultPrefix(this.dispatchState.pendingForkResults.splice(0));
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 true;
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
- cursor = page.has_more ? page.next_cursor : undefined;
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.18.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.18.0"
25
+ "@parall/sdk": "1.19.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^22.0.0",
@@ -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
- if (earlier.length > 0) {
504
- await this.createInputStepsForEarlierEvents(earlier);
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 forkPrefix = buildForkResultPrefix(this.dispatchState.pendingForkResults.splice(0));
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 forkPrefix = buildForkResultPrefix(this.dispatchState.pendingForkResults.splice(0));
687
+ const pendingFork = this.dispatchState.pendingForkResults.splice(0);
688
+ const forkPrefix = buildForkResultPrefix(pendingFork);
592
689
  this.dispatchState.mainCurrentTargetId = event.targetId;
593
- if (earlier.length > 0) {
594
- await this.createInputStepsForEarlierEvents(earlier);
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 forkPrefix = buildForkResultPrefix(this.dispatchState.pendingForkResults.splice(0));
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 true;
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
- cursor = page.has_more ? page.next_cursor : undefined;
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);