@parall/agent-core 1.18.0 → 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/bridge-workspace.d.ts +40 -0
- package/dist/bridge-workspace.d.ts.map +1 -0
- package/dist/bridge-workspace.js +113 -0
- 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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/package.json +2 -2
- package/src/bridge-workspace.ts +107 -0
- package/src/gateway-base.ts +175 -14
- package/src/index.ts +1 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared workspace instructions seeded into every self-hosted bridge runtime
|
|
3
|
+
* (Claude Code writes this to `CLAUDE.md`, Codex to `AGENTS.md`). The text and
|
|
4
|
+
* the Parall CLI detection helpers live together so the seeded command
|
|
5
|
+
* examples and the runtime-side suppression logic cannot drift apart. Each
|
|
6
|
+
* bridge package is responsible for its own filesystem write —
|
|
7
|
+
* `@parall/agent-core` stays runtime-neutral and exports only text + pure
|
|
8
|
+
* helpers here.
|
|
9
|
+
*/
|
|
10
|
+
export declare const BRIDGE_WORKSPACE_INSTRUCTIONS = "# Agent workspace\n\nYou are an agent in Parall IM. You participate in chats, handle tasks, and interact exclusively through the Parall CLI.\n\n## Message Model\n\nIncoming events are rendered as structured `[Event: ...]` blocks.\nEach event includes `[Chat: ... (prll://cht_xxx)]` \u2014 use that chat ID (or full URI) when replying.\n\n**Your plain-text output is not delivered to anyone** \u2014 it is recorded as suppressed thinking in your session steps and discarded from the chat.\nTo say something in a chat, you **must** invoke the Parall CLI via your shell/exec tool. To stay silent, simply do not invoke it.\n\n## Parall CLI\n\nAll outbound interactions go through `@parall/cli`. Credentials are pre-injected as environment variables \u2014 no setup needed.\n\n- `npx --yes @parall/cli@latest messages send prll://cht_xxx --text \"...\"` \u2014 reply into the triggering chat\n- `npx --yes @parall/cli@latest dm prll://usr_xxx --text \"...\" [--no-reply]` \u2014 direct message another user\n- `npx --yes @parall/cli@latest tasks update prll://tsk_xxx --status in_progress` \u2014 task state\n- `npx --yes @parall/cli@latest no-reply [--reason \"...\"]` \u2014 explicitly declare this turn silent (audit signal; not required for silence, just clarifies intent)\n\nThe bridge injects Parall context via environment variables. The static credentials `PRLL_API_URL`, `PRLL_API_KEY`, and `PRLL_ORG_ID` are always set. Per-dispatch context \u2014 `PRLL_SESSION_ID`, `PRLL_CHAT_ID`, `PRLL_TRIGGER_MESSAGE_ID`, `PRLL_STEP_ID_FILE` \u2014 is set in subprocess-per-dispatch runtimes (Claude Code); in long-running runtimes (Codex) those may be absent and the CLI will fall back to whatever defaults you supply on the command line.\n\n## Guardrails\n\n- A dispatch may coalesce multiple events. Decide per event whether to reply via `messages send` / `dm` \u2014 events you do not act on simply receive no reply.\n- If an event carries `[Hint: no_reply]`, do not send anything for that event. `no-reply` is optional and only useful as an explicit intent marker.\n- Never try to \"speak\" by typing sentences like \"No response needed\" / \"Noted\" / \"OK\" \u2014 they are discarded, so they accomplish nothing except polluting your session log.\n- Keep CLI replies concise and task-focused.\n\nSee `docs/engineering-design/agent-dm-loop-prevention.md` \u00A7 Layer 0 for why plain text is never auto-projected.\n";
|
|
11
|
+
/** Extracts the `command` string from a shell/bash tool call's input payload. */
|
|
12
|
+
export declare function extractShellCommand(input: unknown): string | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Returns the non-flag tokens that follow a Parall CLI invocation in
|
|
15
|
+
* `command`, or `null` if the command isn't a Parall CLI call.
|
|
16
|
+
*
|
|
17
|
+
* Handles all documented launch forms — bare `parall`, `npx [--yes|-y]
|
|
18
|
+
* @parall/cli[@version]`, `pnpm (exec|dlx) parall` — and skips npx flags so
|
|
19
|
+
* `npx --yes` matches the same way as `npx -y`. Uses space-delimited
|
|
20
|
+
* tokenization rather than a regex so that `messages send --no-reply` is
|
|
21
|
+
* correctly classified as a `messages send` invocation (not the receiver-side
|
|
22
|
+
* `no-reply` subcommand).
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseParallCliInvocation(command: string): string[] | null;
|
|
25
|
+
/**
|
|
26
|
+
* Detects whether a shell command invokes the Parall CLI to send a
|
|
27
|
+
* chat-visible side effect (`messages send` or `dm`). Bridge adapters use
|
|
28
|
+
* this to suppress the immediately-following runtime text so the user
|
|
29
|
+
* doesn't see the same message twice — once from the CLI call, once
|
|
30
|
+
* projected from runtime output.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isParallSendCommand(command: string | undefined): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Detects whether a shell command invokes `parall no-reply`, which signals
|
|
35
|
+
* the agent wants the whole turn silenced. Bridge adapters use this to
|
|
36
|
+
* flip a sticky suppression flag for the rest of the dispatch so no text
|
|
37
|
+
* event from that turn is projected into chat.
|
|
38
|
+
*/
|
|
39
|
+
export declare function isParallNoReplyCommand(command: string | undefined): boolean;
|
|
40
|
+
//# sourceMappingURL=bridge-workspace.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bridge-workspace.d.ts","sourceRoot":"","sources":["../src/bridge-workspace.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,eAAO,MAAM,6BAA6B,m3EA+BzC,CAAC;AAEF,iFAAiF;AACjF,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAItE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAmBzE;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAKxE;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAI3E"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared workspace instructions seeded into every self-hosted bridge runtime
|
|
3
|
+
* (Claude Code writes this to `CLAUDE.md`, Codex to `AGENTS.md`). The text and
|
|
4
|
+
* the Parall CLI detection helpers live together so the seeded command
|
|
5
|
+
* examples and the runtime-side suppression logic cannot drift apart. Each
|
|
6
|
+
* bridge package is responsible for its own filesystem write —
|
|
7
|
+
* `@parall/agent-core` stays runtime-neutral and exports only text + pure
|
|
8
|
+
* helpers here.
|
|
9
|
+
*/
|
|
10
|
+
export const BRIDGE_WORKSPACE_INSTRUCTIONS = `# Agent workspace
|
|
11
|
+
|
|
12
|
+
You are an agent in Parall IM. You participate in chats, handle tasks, and interact exclusively through the Parall CLI.
|
|
13
|
+
|
|
14
|
+
## Message Model
|
|
15
|
+
|
|
16
|
+
Incoming events are rendered as structured \`[Event: ...]\` blocks.
|
|
17
|
+
Each event includes \`[Chat: ... (prll://cht_xxx)]\` — use that chat ID (or full URI) when replying.
|
|
18
|
+
|
|
19
|
+
**Your plain-text output is not delivered to anyone** — it is recorded as suppressed thinking in your session steps and discarded from the chat.
|
|
20
|
+
To say something in a chat, you **must** invoke the Parall CLI via your shell/exec tool. To stay silent, simply do not invoke it.
|
|
21
|
+
|
|
22
|
+
## Parall CLI
|
|
23
|
+
|
|
24
|
+
All outbound interactions go through \`@parall/cli\`. Credentials are pre-injected as environment variables — no setup needed.
|
|
25
|
+
|
|
26
|
+
- \`npx --yes @parall/cli@latest messages send prll://cht_xxx --text "..."\` — reply into the triggering chat
|
|
27
|
+
- \`npx --yes @parall/cli@latest dm prll://usr_xxx --text "..." [--no-reply]\` — direct message another user
|
|
28
|
+
- \`npx --yes @parall/cli@latest tasks update prll://tsk_xxx --status in_progress\` — task state
|
|
29
|
+
- \`npx --yes @parall/cli@latest no-reply [--reason "..."]\` — explicitly declare this turn silent (audit signal; not required for silence, just clarifies intent)
|
|
30
|
+
|
|
31
|
+
The bridge injects Parall context via environment variables. The static credentials \`PRLL_API_URL\`, \`PRLL_API_KEY\`, and \`PRLL_ORG_ID\` are always set. Per-dispatch context — \`PRLL_SESSION_ID\`, \`PRLL_CHAT_ID\`, \`PRLL_TRIGGER_MESSAGE_ID\`, \`PRLL_STEP_ID_FILE\` — is set in subprocess-per-dispatch runtimes (Claude Code); in long-running runtimes (Codex) those may be absent and the CLI will fall back to whatever defaults you supply on the command line.
|
|
32
|
+
|
|
33
|
+
## Guardrails
|
|
34
|
+
|
|
35
|
+
- A dispatch may coalesce multiple events. Decide per event whether to reply via \`messages send\` / \`dm\` — events you do not act on simply receive no reply.
|
|
36
|
+
- If an event carries \`[Hint: no_reply]\`, do not send anything for that event. \`no-reply\` is optional and only useful as an explicit intent marker.
|
|
37
|
+
- Never try to "speak" by typing sentences like "No response needed" / "Noted" / "OK" — they are discarded, so they accomplish nothing except polluting your session log.
|
|
38
|
+
- Keep CLI replies concise and task-focused.
|
|
39
|
+
|
|
40
|
+
See \`docs/engineering-design/agent-dm-loop-prevention.md\` § Layer 0 for why plain text is never auto-projected.
|
|
41
|
+
`;
|
|
42
|
+
/** Extracts the `command` string from a shell/bash tool call's input payload. */
|
|
43
|
+
export function extractShellCommand(input) {
|
|
44
|
+
if (!input || typeof input !== "object")
|
|
45
|
+
return undefined;
|
|
46
|
+
const command = input.command;
|
|
47
|
+
return typeof command === "string" && command.trim() ? command.trim() : undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Returns the non-flag tokens that follow a Parall CLI invocation in
|
|
51
|
+
* `command`, or `null` if the command isn't a Parall CLI call.
|
|
52
|
+
*
|
|
53
|
+
* Handles all documented launch forms — bare `parall`, `npx [--yes|-y]
|
|
54
|
+
* @parall/cli[@version]`, `pnpm (exec|dlx) parall` — and skips npx flags so
|
|
55
|
+
* `npx --yes` matches the same way as `npx -y`. Uses space-delimited
|
|
56
|
+
* tokenization rather than a regex so that `messages send --no-reply` is
|
|
57
|
+
* correctly classified as a `messages send` invocation (not the receiver-side
|
|
58
|
+
* `no-reply` subcommand).
|
|
59
|
+
*/
|
|
60
|
+
export function parseParallCliInvocation(command) {
|
|
61
|
+
const tokens = command.replace(/\s+/g, " ").trim().split(" ");
|
|
62
|
+
let i = 0;
|
|
63
|
+
if (tokens[i] === "parall") {
|
|
64
|
+
i++;
|
|
65
|
+
}
|
|
66
|
+
else if (tokens[i] === "npx") {
|
|
67
|
+
i++;
|
|
68
|
+
while (i < tokens.length && tokens[i].startsWith("-"))
|
|
69
|
+
i++;
|
|
70
|
+
if (i >= tokens.length || !/^@parall\/cli(?:@.+)?$/.test(tokens[i]))
|
|
71
|
+
return null;
|
|
72
|
+
i++;
|
|
73
|
+
}
|
|
74
|
+
else if (tokens[i] === "pnpm") {
|
|
75
|
+
i++;
|
|
76
|
+
if (i < tokens.length && (tokens[i] === "exec" || tokens[i] === "dlx"))
|
|
77
|
+
i++;
|
|
78
|
+
if (i >= tokens.length || tokens[i] !== "parall")
|
|
79
|
+
return null;
|
|
80
|
+
i++;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return tokens.slice(i).filter((t) => !t.startsWith("-"));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Detects whether a shell command invokes the Parall CLI to send a
|
|
89
|
+
* chat-visible side effect (`messages send` or `dm`). Bridge adapters use
|
|
90
|
+
* this to suppress the immediately-following runtime text so the user
|
|
91
|
+
* doesn't see the same message twice — once from the CLI call, once
|
|
92
|
+
* projected from runtime output.
|
|
93
|
+
*/
|
|
94
|
+
export function isParallSendCommand(command) {
|
|
95
|
+
if (!command)
|
|
96
|
+
return false;
|
|
97
|
+
const sub = parseParallCliInvocation(command);
|
|
98
|
+
if (!sub || sub.length === 0)
|
|
99
|
+
return false;
|
|
100
|
+
return sub[0] === "dm" || (sub[0] === "messages" && sub[1] === "send");
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Detects whether a shell command invokes `parall no-reply`, which signals
|
|
104
|
+
* the agent wants the whole turn silenced. Bridge adapters use this to
|
|
105
|
+
* flip a sticky suppression flag for the rest of the dispatch so no text
|
|
106
|
+
* event from that turn is projected into chat.
|
|
107
|
+
*/
|
|
108
|
+
export function isParallNoReplyCommand(command) {
|
|
109
|
+
if (!command)
|
|
110
|
+
return false;
|
|
111
|
+
const sub = parseParallCliInvocation(command);
|
|
112
|
+
return sub?.[0] === "no-reply";
|
|
113
|
+
}
|
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":"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;IAI3B,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,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;AAUF,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAKnF;AAkCD,qBAAa,kBAAkB;IAmCjB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAlCjC,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;IAMzB,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,kBAAkB,CAAK;IAC/B,OAAO,CAAC,cAAc,CAAyB;IAE/C,OAAO,CAAC,QAAQ,CAAC,uBAAuB,CAAQ;IAChD,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAS;IAI9C,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAS;gBAEjB,IAAI,EAAE,oBAAoB;IAKjD,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;YAUhC,WAAW;YAoFX,gBAAgB;YAkFhB,eAAe;YA6Ef,kBAAkB;YA2ElB,kBAAkB;YAkBlB,4BAA4B;YAoD5B,aAAa;YA+Bb,oBAAoB;YAoCpB,iBAAiB;YAsEjB,mBAAmB;YA2GnB,WAAW;IA6DzB,OAAO,CAAC,YAAY;YAiBN,QAAQ;CA4CvB"}
|
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/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from "./session-state.js";
|
|
|
3
3
|
export * from "./routing.js";
|
|
4
4
|
export * from "./event-format.js";
|
|
5
5
|
export * from "./prompt-fragments.js";
|
|
6
|
+
export * from "./bridge-workspace.js";
|
|
6
7
|
export * from "./dispatch-adapter.js";
|
|
7
8
|
export * from "./gateway-base.js";
|
|
8
9
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,oBAAoB,CAAC;AACnC,cAAc,cAAc,CAAC;AAC7B,cAAc,mBAAmB,CAAC;AAClC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,mBAAmB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,oBAAoB,CAAC;AACnC,cAAc,cAAc,CAAC;AAC7B,cAAc,mBAAmB,CAAC;AAClC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,mBAAmB,CAAC"}
|
package/dist/index.js
CHANGED
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",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared workspace instructions seeded into every self-hosted bridge runtime
|
|
3
|
+
* (Claude Code writes this to `CLAUDE.md`, Codex to `AGENTS.md`). The text and
|
|
4
|
+
* the Parall CLI detection helpers live together so the seeded command
|
|
5
|
+
* examples and the runtime-side suppression logic cannot drift apart. Each
|
|
6
|
+
* bridge package is responsible for its own filesystem write —
|
|
7
|
+
* `@parall/agent-core` stays runtime-neutral and exports only text + pure
|
|
8
|
+
* helpers here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const BRIDGE_WORKSPACE_INSTRUCTIONS = `# Agent workspace
|
|
12
|
+
|
|
13
|
+
You are an agent in Parall IM. You participate in chats, handle tasks, and interact exclusively through the Parall CLI.
|
|
14
|
+
|
|
15
|
+
## Message Model
|
|
16
|
+
|
|
17
|
+
Incoming events are rendered as structured \`[Event: ...]\` blocks.
|
|
18
|
+
Each event includes \`[Chat: ... (prll://cht_xxx)]\` — use that chat ID (or full URI) when replying.
|
|
19
|
+
|
|
20
|
+
**Your plain-text output is not delivered to anyone** — it is recorded as suppressed thinking in your session steps and discarded from the chat.
|
|
21
|
+
To say something in a chat, you **must** invoke the Parall CLI via your shell/exec tool. To stay silent, simply do not invoke it.
|
|
22
|
+
|
|
23
|
+
## Parall CLI
|
|
24
|
+
|
|
25
|
+
All outbound interactions go through \`@parall/cli\`. Credentials are pre-injected as environment variables — no setup needed.
|
|
26
|
+
|
|
27
|
+
- \`npx --yes @parall/cli@latest messages send prll://cht_xxx --text "..."\` — reply into the triggering chat
|
|
28
|
+
- \`npx --yes @parall/cli@latest dm prll://usr_xxx --text "..." [--no-reply]\` — direct message another user
|
|
29
|
+
- \`npx --yes @parall/cli@latest tasks update prll://tsk_xxx --status in_progress\` — task state
|
|
30
|
+
- \`npx --yes @parall/cli@latest no-reply [--reason "..."]\` — explicitly declare this turn silent (audit signal; not required for silence, just clarifies intent)
|
|
31
|
+
|
|
32
|
+
The bridge injects Parall context via environment variables. The static credentials \`PRLL_API_URL\`, \`PRLL_API_KEY\`, and \`PRLL_ORG_ID\` are always set. Per-dispatch context — \`PRLL_SESSION_ID\`, \`PRLL_CHAT_ID\`, \`PRLL_TRIGGER_MESSAGE_ID\`, \`PRLL_STEP_ID_FILE\` — is set in subprocess-per-dispatch runtimes (Claude Code); in long-running runtimes (Codex) those may be absent and the CLI will fall back to whatever defaults you supply on the command line.
|
|
33
|
+
|
|
34
|
+
## Guardrails
|
|
35
|
+
|
|
36
|
+
- A dispatch may coalesce multiple events. Decide per event whether to reply via \`messages send\` / \`dm\` — events you do not act on simply receive no reply.
|
|
37
|
+
- If an event carries \`[Hint: no_reply]\`, do not send anything for that event. \`no-reply\` is optional and only useful as an explicit intent marker.
|
|
38
|
+
- Never try to "speak" by typing sentences like "No response needed" / "Noted" / "OK" — they are discarded, so they accomplish nothing except polluting your session log.
|
|
39
|
+
- Keep CLI replies concise and task-focused.
|
|
40
|
+
|
|
41
|
+
See \`docs/engineering-design/agent-dm-loop-prevention.md\` § Layer 0 for why plain text is never auto-projected.
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
/** Extracts the `command` string from a shell/bash tool call's input payload. */
|
|
45
|
+
export function extractShellCommand(input: unknown): string | undefined {
|
|
46
|
+
if (!input || typeof input !== "object") return undefined;
|
|
47
|
+
const command = (input as { command?: unknown }).command;
|
|
48
|
+
return typeof command === "string" && command.trim() ? command.trim() : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Returns the non-flag tokens that follow a Parall CLI invocation in
|
|
53
|
+
* `command`, or `null` if the command isn't a Parall CLI call.
|
|
54
|
+
*
|
|
55
|
+
* Handles all documented launch forms — bare `parall`, `npx [--yes|-y]
|
|
56
|
+
* @parall/cli[@version]`, `pnpm (exec|dlx) parall` — and skips npx flags so
|
|
57
|
+
* `npx --yes` matches the same way as `npx -y`. Uses space-delimited
|
|
58
|
+
* tokenization rather than a regex so that `messages send --no-reply` is
|
|
59
|
+
* correctly classified as a `messages send` invocation (not the receiver-side
|
|
60
|
+
* `no-reply` subcommand).
|
|
61
|
+
*/
|
|
62
|
+
export function parseParallCliInvocation(command: string): string[] | null {
|
|
63
|
+
const tokens = command.replace(/\s+/g, " ").trim().split(" ");
|
|
64
|
+
let i = 0;
|
|
65
|
+
if (tokens[i] === "parall") {
|
|
66
|
+
i++;
|
|
67
|
+
} else if (tokens[i] === "npx") {
|
|
68
|
+
i++;
|
|
69
|
+
while (i < tokens.length && tokens[i].startsWith("-")) i++;
|
|
70
|
+
if (i >= tokens.length || !/^@parall\/cli(?:@.+)?$/.test(tokens[i])) return null;
|
|
71
|
+
i++;
|
|
72
|
+
} else if (tokens[i] === "pnpm") {
|
|
73
|
+
i++;
|
|
74
|
+
if (i < tokens.length && (tokens[i] === "exec" || tokens[i] === "dlx")) i++;
|
|
75
|
+
if (i >= tokens.length || tokens[i] !== "parall") return null;
|
|
76
|
+
i++;
|
|
77
|
+
} else {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return tokens.slice(i).filter((t) => !t.startsWith("-"));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Detects whether a shell command invokes the Parall CLI to send a
|
|
85
|
+
* chat-visible side effect (`messages send` or `dm`). Bridge adapters use
|
|
86
|
+
* this to suppress the immediately-following runtime text so the user
|
|
87
|
+
* doesn't see the same message twice — once from the CLI call, once
|
|
88
|
+
* projected from runtime output.
|
|
89
|
+
*/
|
|
90
|
+
export function isParallSendCommand(command: string | undefined): boolean {
|
|
91
|
+
if (!command) return false;
|
|
92
|
+
const sub = parseParallCliInvocation(command);
|
|
93
|
+
if (!sub || sub.length === 0) return false;
|
|
94
|
+
return sub[0] === "dm" || (sub[0] === "messages" && sub[1] === "send");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Detects whether a shell command invokes `parall no-reply`, which signals
|
|
99
|
+
* the agent wants the whole turn silenced. Bridge adapters use this to
|
|
100
|
+
* flip a sticky suppression flag for the rest of the dispatch so no text
|
|
101
|
+
* event from that turn is projected into chat.
|
|
102
|
+
*/
|
|
103
|
+
export function isParallNoReplyCommand(command: string | undefined): boolean {
|
|
104
|
+
if (!command) return false;
|
|
105
|
+
const sub = parseParallCliInvocation(command);
|
|
106
|
+
return sub?.[0] === "no-reply";
|
|
107
|
+
}
|
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/index.ts
CHANGED