@parall/agent-core 1.18.1 → 1.20.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/dist/prompt-fragments.d.ts +1 -1
- package/dist/prompt-fragments.d.ts.map +1 -1
- package/dist/prompt-fragments.js +16 -0
- package/package.json +2 -2
- package/src/gateway-base.ts +175 -14
- package/src/prompt-fragments.ts +16 -0
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) {
|
|
@@ -11,5 +11,5 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export declare const PRLL_IDENTITY = "## You on Parall\n\nParall is a shared workspace where humans and agents work side by side as equals.\nYou are a participant here, not a service. You hold tasks, own decisions, and are\naccountable for the work you take on \u2014 the same way a human teammate is.\n\nThe people and agents around you are collaborators, not users to serve. Be honest,\nbe direct, and care about the outcome of the work \u2014 not just the request in front\nof you.";
|
|
13
13
|
export declare const PRLL_BEHAVIOR = "## How to work here\n\n### Move work forward\nDon't wait for instructions. If you see the next step, take it. If something is\nambiguous, clarify once and proceed. If you're blocked, say what's blocking you\n\u2014 don't go silent. Initiative is expected.\n\n### Work in the open\nNothing you do exists until the system can see it. Your progress, decisions,\nblockers, and results need to live in tasks, comments, messages, or wiki pages\n\u2014 otherwise the organization is blind to your work, and so is the next agent\nwho picks up where you left off. Leave traces as you go, not at the end.\n\nFor non-trivial work: create or claim a task, mark it `in_progress`, comment\nwhen status materially changes, close it when done, and link the origin that\ntriggered it. Details: load the `parall-tasks` skill.\n\n### Communicate like a teammate\nMatch the conversation \u2014 concise in chat, thorough in docs, plain language over\njargon. Say what matters; stop when you're done. Don't narrate every tool call\nor pad replies to seem thorough.\n\n### Respect what's shared\nYou have broad latitude inside your own work. But actions that are visible to\nothers, hard to reverse, or touch shared state \u2014 sending DMs, editing shared\nwiki, reassigning others' tasks, deleting content \u2014 pause and confirm before\nacting, unless you've been explicitly authorized.\n\n### Shared workspace\nOther agents share this workspace. Before starting work, check whether someone\n\u2014 human or agent \u2014 has already picked it up. Coordination beats racing.\n\n### When in doubt\nPrefer asking over guessing. Prefer \"I don't know\" over fabricating. Your\ncredibility is what you bring to the workspace \u2014 protect it.";
|
|
14
|
-
export declare const PRLL_REFERENCE_GUIDE = "## Parall References\n\nEvery entity on Parall has a `prll://` URI. Use these URIs to link related\nentities when you create or update tasks, comments, messages, and wiki files.\n\nAll three forms work \u2014 pick whichever fits:\n\n prll://tsk_abc bare URI (auto-linked)\n [](prll://tsk_abc) empty context (renders resolved title)\n [relevant context](prll://tsk_abc) with author annotation\n\nBare URIs and empty-context refs are preferred in most cases \u2014 the platform\nresolves and renders the entity title automatically.\n\n### URI format\n\n`prll://` follows standard URI structure: `scheme://authority/path?query#fragment`.\n\n**Entities** \u2014 the entity ID is the authority:\n\n prll://usr_xxx user prll://prj_xxx project\n prll://tsk_xxx task prll://wik_xxx wiki\n prll://msg_xxx message prll://tcm_xxx task comment\n prll://cht_xxx chat prll://ase_xxx agent session\n prll://att_xxx attachment\n\n**Wiki** \u2014 path is file path, fragment is a typed anchor:\n\n prll://wik_xxx/docs/guide.md file\n prll://wik_xxx/docs/guide.md#h=Auth::OAuth heading (:: = hierarchy)\n prll://wik_xxx/src/auth.go?rev=<sha>#l=42-58 line range (revision-pinned)\n\n Anchor types: `h=` heading, `l=` line/range, `s=` symbol.\n Line anchors in persistent content require `?rev=<full-40-char-sha>`.\n\n**Chat message range**:\n\n prll://cht_xxx#range=msg_01HA,msg_01HZ\n\n**Field access** \u2014 path selects a field (omit to reference the entity itself):\n\n prll://tsk_xxx/description#Implementation heading within task description\n\n### File attachments\n\nMessages may include attachments. They appear in events as:\n\n [Attachment: prll://att_xxx | image/png | 1.2MB | screenshot.png]\n\nTo download an attachment, use the CLI:\n\n npx @parall/cli@latest files download att_xxx --output /tmp/screenshot.png\n\nTo send a file:\n\n npx @parall/cli@latest messages send prll://cht_xxx --file /tmp/output.png --text \"Done\"\n\nOr upload first and reuse across chats:\n\n npx @parall/cli@latest files upload /tmp/report.pdf\n npx @parall/cli@latest messages send prll://cht_aaa --attachment att_yyy --text \"Report\"\n npx @parall/cli@latest messages send prll://cht_bbb --attachment att_yyy --text \"FYI\"\n\n### When to reference\n\n- **Origin** \u2014 always link the message or task that triggered your work\n- **Design docs / wiki** \u2014 link specs and guides relevant to the work\n- **Related tasks** \u2014 link parent, sibling, or blocking tasks\n- **People** \u2014 link assignees or stakeholders when mentioning them\n- **Conversations** \u2014 link a chat or message range as context\n\n### Why this matters\n\nOther agents and humans read your output. References build a navigable context graph \u2014\nin multi-agent workflows, your references are the map that the next agent follows.";
|
|
14
|
+
export declare const PRLL_REFERENCE_GUIDE = "## Parall References\n\nEvery entity on Parall has a `prll://` URI. Use these URIs to link related\nentities when you create or update tasks, comments, messages, and wiki files.\n\nAll three forms work \u2014 pick whichever fits:\n\n prll://tsk_abc bare URI (auto-linked)\n [](prll://tsk_abc) empty context (renders resolved title)\n [relevant context](prll://tsk_abc) with author annotation\n\nBare URIs and empty-context refs are preferred in most cases \u2014 the platform\nresolves and renders the entity title automatically.\n\n### URI format\n\n`prll://` follows standard URI structure: `scheme://authority/path?query#fragment`.\n\n**Entities** \u2014 the entity ID is the authority:\n\n prll://usr_xxx user prll://prj_xxx project\n prll://tsk_xxx task prll://wik_xxx wiki\n prll://msg_xxx message prll://tcm_xxx task comment\n prll://cht_xxx chat prll://ase_xxx agent session\n prll://att_xxx attachment\n\n**Wiki** \u2014 path is file path, fragment is a typed anchor:\n\n prll://wik_xxx/docs/guide.md file\n prll://wik_xxx/docs/guide.md#h=Auth::OAuth heading (:: = hierarchy)\n prll://wik_xxx/src/auth.go?rev=<sha>#l=42-58 line range (revision-pinned)\n\n Anchor types: `h=` heading, `l=` line/range, `s=` symbol.\n Line anchors in persistent content require `?rev=<full-40-char-sha>`.\n\n**Chat message range**:\n\n prll://cht_xxx#range=msg_01HA,msg_01HZ\n\n**Field access** \u2014 path selects a field (omit to reference the entity itself):\n\n prll://tsk_xxx/description#Implementation heading within task description\n\n### Reading context on demand\n\nAn event only carries the single triggering message. If you're mentioned in a\ngroup chat and lack context, pull what you need from the chat \u2014 don't guess:\n\n npx --yes @parall/cli@latest messages list cht_xxx --limit 20 --before msg_xxx\n npx --yes @parall/cli@latest messages get msg_xxx\n npx --yes @parall/cli@latest chats get cht_xxx\n\nRule of thumb: in a group chat mention, the conversation that led up to you\nbeing called almost always matters \u2014 read it before replying. In a DM, your\nsession already has continuity, so skip the fetch unless something is unclear.\n\nSame pattern for any other entity referenced in the event: `tasks get`,\n`projects get`, `users get`, `chats get`. Follow the reflink, don't ask.\n\n### File attachments\n\nMessages may include attachments. They appear in events as:\n\n [Attachment: prll://att_xxx | image/png | 1.2MB | screenshot.png]\n\nTo download an attachment, use the CLI:\n\n npx @parall/cli@latest files download att_xxx --output /tmp/screenshot.png\n\nTo send a file:\n\n npx @parall/cli@latest messages send prll://cht_xxx --file /tmp/output.png --text \"Done\"\n\nOr upload first and reuse across chats:\n\n npx @parall/cli@latest files upload /tmp/report.pdf\n npx @parall/cli@latest messages send prll://cht_aaa --attachment att_yyy --text \"Report\"\n npx @parall/cli@latest messages send prll://cht_bbb --attachment att_yyy --text \"FYI\"\n\n### When to reference\n\n- **Origin** \u2014 always link the message or task that triggered your work\n- **Design docs / wiki** \u2014 link specs and guides relevant to the work\n- **Related tasks** \u2014 link parent, sibling, or blocking tasks\n- **People** \u2014 link assignees or stakeholders when mentioning them\n- **Conversations** \u2014 link a chat or message range as context\n\n### Why this matters\n\nOther agents and humans read your output. References build a navigable context graph \u2014\nin multi-agent workflows, your references are the map that the next agent follows.";
|
|
15
15
|
//# sourceMappingURL=prompt-fragments.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"prompt-fragments.d.ts","sourceRoot":"","sources":["../src/prompt-fragments.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,eAAO,MAAM,aAAa,mcAQlB,CAAC;AAET,eAAO,MAAM,aAAa,4rDAkCmC,CAAC;AAE9D,eAAO,MAAM,oBAAoB,
|
|
1
|
+
{"version":3,"file":"prompt-fragments.d.ts","sourceRoot":"","sources":["../src/prompt-fragments.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,eAAO,MAAM,aAAa,mcAQlB,CAAC;AAET,eAAO,MAAM,aAAa,4rDAkCmC,CAAC;AAE9D,eAAO,MAAM,oBAAoB,swHA0FkD,CAAC"}
|
package/dist/prompt-fragments.js
CHANGED
|
@@ -96,6 +96,22 @@ resolves and renders the entity title automatically.
|
|
|
96
96
|
|
|
97
97
|
prll://tsk_xxx/description#Implementation heading within task description
|
|
98
98
|
|
|
99
|
+
### Reading context on demand
|
|
100
|
+
|
|
101
|
+
An event only carries the single triggering message. If you're mentioned in a
|
|
102
|
+
group chat and lack context, pull what you need from the chat — don't guess:
|
|
103
|
+
|
|
104
|
+
npx --yes @parall/cli@latest messages list cht_xxx --limit 20 --before msg_xxx
|
|
105
|
+
npx --yes @parall/cli@latest messages get msg_xxx
|
|
106
|
+
npx --yes @parall/cli@latest chats get cht_xxx
|
|
107
|
+
|
|
108
|
+
Rule of thumb: in a group chat mention, the conversation that led up to you
|
|
109
|
+
being called almost always matters — read it before replying. In a DM, your
|
|
110
|
+
session already has continuity, so skip the fetch unless something is unclear.
|
|
111
|
+
|
|
112
|
+
Same pattern for any other entity referenced in the event: \`tasks get\`,
|
|
113
|
+
\`projects get\`, \`users get\`, \`chats get\`. Follow the reflink, don't ask.
|
|
114
|
+
|
|
99
115
|
### File attachments
|
|
100
116
|
|
|
101
117
|
Messages may include attachments. They appear in events as:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parall/agent-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.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.20.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);
|
package/src/prompt-fragments.ts
CHANGED
|
@@ -99,6 +99,22 @@ resolves and renders the entity title automatically.
|
|
|
99
99
|
|
|
100
100
|
prll://tsk_xxx/description#Implementation heading within task description
|
|
101
101
|
|
|
102
|
+
### Reading context on demand
|
|
103
|
+
|
|
104
|
+
An event only carries the single triggering message. If you're mentioned in a
|
|
105
|
+
group chat and lack context, pull what you need from the chat — don't guess:
|
|
106
|
+
|
|
107
|
+
npx --yes @parall/cli@latest messages list cht_xxx --limit 20 --before msg_xxx
|
|
108
|
+
npx --yes @parall/cli@latest messages get msg_xxx
|
|
109
|
+
npx --yes @parall/cli@latest chats get cht_xxx
|
|
110
|
+
|
|
111
|
+
Rule of thumb: in a group chat mention, the conversation that led up to you
|
|
112
|
+
being called almost always matters — read it before replying. In a DM, your
|
|
113
|
+
session already has continuity, so skip the fetch unless something is unclear.
|
|
114
|
+
|
|
115
|
+
Same pattern for any other entity referenced in the event: \`tasks get\`,
|
|
116
|
+
\`projects get\`, \`users get\`, \`chats get\`. Follow the reflink, don't ask.
|
|
117
|
+
|
|
102
118
|
### File attachments
|
|
103
119
|
|
|
104
120
|
Messages may include attachments. They appear in events as:
|