@parall/parall 1.12.0 → 1.13.1
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/package.json +4 -3
- package/src/fork.ts +5 -28
- package/src/gateway.ts +210 -1098
- package/src/hooks.ts +6 -26
- package/src/routing.ts +8 -48
- package/src/runtime.ts +30 -101
package/src/gateway.ts
CHANGED
|
@@ -3,8 +3,13 @@ import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
|
3
3
|
|
|
4
4
|
/** Extract ChannelGatewayAdapter from ChannelPlugin (removed from public SDK exports in 2026.3.24). */
|
|
5
5
|
type ChannelGatewayAdapter<T = unknown> = NonNullable<ChannelPlugin<T>["gateway"]>;
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
ParallAgentGateway,
|
|
8
|
+
type DispatchAdapter,
|
|
9
|
+
type ParallEvent,
|
|
10
|
+
type RuntimeEvent,
|
|
11
|
+
} from "@parall/agent-core";
|
|
12
|
+
import { ParallClient, ParallWs } from "@parall/sdk";
|
|
8
13
|
import type { ParallChannelConfig } from "./types.js";
|
|
9
14
|
import * as crypto from "node:crypto";
|
|
10
15
|
import * as os from "node:os";
|
|
@@ -12,22 +17,15 @@ import * as path from "node:path";
|
|
|
12
17
|
import { resolveParallAccount } from "./accounts.js";
|
|
13
18
|
import {
|
|
14
19
|
getParallRuntime,
|
|
15
|
-
setParallAccountState,
|
|
16
|
-
getParallAccountState,
|
|
17
20
|
removeParallAccountState,
|
|
18
|
-
setSessionChatId,
|
|
19
|
-
setSessionMessageId,
|
|
20
|
-
setDispatchMessageId,
|
|
21
21
|
setDispatchGroupKey,
|
|
22
|
-
|
|
22
|
+
setParallAccountState,
|
|
23
23
|
} from "./runtime.js";
|
|
24
|
-
import type { ParallEvent, DispatchState, ForkResult } from "./runtime.js";
|
|
25
24
|
import { buildOrchestratorSessionKey } from "./session.js";
|
|
26
25
|
import type { ResolvedParallAccount } from "./types.js";
|
|
27
26
|
import { fetchAndApplyPlatformConfig } from "./config-manager.js";
|
|
28
27
|
import { startWikiHelper } from "./wiki-helper.js";
|
|
29
|
-
import {
|
|
30
|
-
import { forkOrchestratorSession, cleanupForkSession, resolveTranscriptFile } from "./fork.js";
|
|
28
|
+
import { cleanupForkSession, forkOrchestratorSession, resolveTranscriptFile } from "./fork.js";
|
|
31
29
|
|
|
32
30
|
function resolveWsUrl(account: ResolvedParallAccount): string {
|
|
33
31
|
if (account.config.ws_url) return account.config.ws_url;
|
|
@@ -36,213 +34,186 @@ function resolveWsUrl(account: ResolvedParallAccount): string {
|
|
|
36
34
|
return `${wsBase}/ws`;
|
|
37
35
|
}
|
|
38
36
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
37
|
+
function buildInboundCtx(
|
|
38
|
+
core: PluginRuntime,
|
|
39
|
+
accountId: string,
|
|
40
|
+
event: ParallEvent,
|
|
41
|
+
sessionKey: string,
|
|
42
|
+
bodyForAgent: string,
|
|
43
|
+
earlierEvents: ParallEvent[] = [],
|
|
44
|
+
): Record<string, unknown> {
|
|
45
|
+
const inboundHistory = earlierEvents.length > 0 ? buildInboundHistory(earlierEvents) : undefined;
|
|
46
|
+
return core.channel.reply.finalizeInboundContext({
|
|
47
|
+
Body: event.body,
|
|
48
|
+
BodyForAgent: bodyForAgent,
|
|
49
|
+
RawBody: event.body,
|
|
50
|
+
CommandBody: event.body,
|
|
51
|
+
...(event.mediaFields ?? {}),
|
|
52
|
+
...(inboundHistory?.length ? { InboundHistory: inboundHistory } : {}),
|
|
53
|
+
From: `parall:${event.senderId}`,
|
|
54
|
+
To: `parall:orchestrator`,
|
|
55
|
+
SessionKey: sessionKey,
|
|
56
|
+
AccountId: accountId,
|
|
57
|
+
ChatType: event.targetType ?? "unknown",
|
|
58
|
+
SenderName: event.senderName,
|
|
59
|
+
SenderId: event.senderId,
|
|
60
|
+
Provider: "parall" as const,
|
|
61
|
+
Surface: "parall" as const,
|
|
62
|
+
MessageSid: event.messageId,
|
|
63
|
+
Timestamp: Date.now(),
|
|
64
|
+
CommandAuthorized: true,
|
|
65
|
+
OriginatingChannel: "parall" as const,
|
|
66
|
+
OriginatingTo: `parall:orchestrator`,
|
|
67
|
+
});
|
|
60
68
|
}
|
|
61
69
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (event.type === "message") {
|
|
69
|
-
lines.push(`[Event: message.new]`);
|
|
70
|
-
const chatLabel = event.targetName
|
|
71
|
-
? `"${event.targetName}" (${event.targetId})`
|
|
72
|
-
: event.targetId;
|
|
73
|
-
lines.push(`[Chat: ${chatLabel} | type: ${event.targetType ?? "unknown"}]`);
|
|
74
|
-
lines.push(`[From: ${event.senderName} (${event.senderId})]`);
|
|
75
|
-
lines.push(`[Message ID: ${event.messageId}]`);
|
|
76
|
-
if (event.threadRootId) lines.push(`[Thread: ${event.threadRootId}]`);
|
|
77
|
-
if (event.noReply) lines.push(`[Hint: no_reply]`);
|
|
70
|
+
function buildInboundHistory(events: ParallEvent[]): Array<{ sender: string; body: string }> {
|
|
71
|
+
return events.map((event) => {
|
|
72
|
+
const meta: string[] = [];
|
|
73
|
+
meta.push(`[Message ID: ${event.messageId}]`);
|
|
74
|
+
if (event.noReply) meta.push(`[Hint: no_reply]`);
|
|
75
|
+
if (event.threadRootId) meta.push(`[Thread: ${event.threadRootId}]`);
|
|
78
76
|
if (event.mediaFields?.MediaUrl) {
|
|
79
|
-
|
|
77
|
+
meta.push(`[Attachment: ${event.mediaFields.MediaType ?? "file"} ${event.mediaFields.MediaUrl}]`);
|
|
80
78
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
? `${event.targetName} (${event.targetId})`
|
|
86
|
-
: event.targetId;
|
|
87
|
-
lines.push(`[Task: ${taskLabel} | type: ${event.targetType ?? "task"}]`);
|
|
88
|
-
lines.push(`[Assigned by: ${event.senderName} (${event.senderId})]`);
|
|
89
|
-
lines.push("", event.body);
|
|
90
|
-
}
|
|
91
|
-
return lines.join("\n");
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function buildForkResultPrefix(results: ForkResult[]): string {
|
|
95
|
-
if (!results.length) return "";
|
|
96
|
-
const blocks = results.map((r) => {
|
|
97
|
-
const lines = [`[Fork result]`];
|
|
98
|
-
lines.push(`[Handled: ${r.sourceEvent.type} — ${r.sourceEvent.summary}]`);
|
|
99
|
-
if (r.actions.length) lines.push(`[Actions: ${r.actions.join("; ")}]`);
|
|
100
|
-
return lines.join("\n");
|
|
79
|
+
return {
|
|
80
|
+
sender: event.senderName,
|
|
81
|
+
body: meta.length > 0 ? `${meta.join(" ")}\n${event.body}` : event.body,
|
|
82
|
+
};
|
|
101
83
|
});
|
|
102
|
-
return blocks.join("\n\n") + "\n\n---\n\n";
|
|
103
84
|
}
|
|
104
85
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
|
|
128
|
-
}) {
|
|
129
|
-
const { core, cfg, client, config, agentUserId, accountId, sessionKey, messageId, inboundCtx, log } = opts;
|
|
130
|
-
const triggerType = opts.triggerType ?? "mention";
|
|
131
|
-
const triggerRef = opts.triggerRef ?? { message_id: messageId };
|
|
132
|
-
|
|
133
|
-
// Resolve session ID from account state
|
|
134
|
-
const state = getParallAccountState(accountId);
|
|
135
|
-
const sessionId = state?.activeSessionId;
|
|
136
|
-
|
|
137
|
-
// Entire dispatch lifecycle in one try/finally: active-set → input step → dispatch → idle.
|
|
138
|
-
// Note: fork-on-busy routing guarantees serial dispatch per session — concurrent
|
|
139
|
-
// dispatches to the same session should not happen in the orchestrator model.
|
|
140
|
-
let reasoningBuffer = "";
|
|
141
|
-
let turnGroupKey = "";
|
|
142
|
-
try {
|
|
143
|
-
// 1. Transition session to active FIRST (before any steps)
|
|
144
|
-
if (sessionId) {
|
|
145
|
-
try {
|
|
146
|
-
await client.updateAgentSession(config.org_id, agentUserId, sessionId, { status: "active" });
|
|
147
|
-
} catch (err) {
|
|
148
|
-
log?.warn(`parall[${accountId}]: failed to set session active: ${String(err)}`);
|
|
86
|
+
function createRuntimeEventStream() {
|
|
87
|
+
const queue: RuntimeEvent[] = [];
|
|
88
|
+
const waiters: Array<{
|
|
89
|
+
resolve: (value: RuntimeEvent | undefined) => void;
|
|
90
|
+
reject: (reason?: unknown) => void;
|
|
91
|
+
}> = [];
|
|
92
|
+
let done = false;
|
|
93
|
+
let failure: unknown;
|
|
94
|
+
|
|
95
|
+
function flush() {
|
|
96
|
+
while (waiters.length > 0) {
|
|
97
|
+
if (failure) {
|
|
98
|
+
waiters.shift()!.reject(failure);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (queue.length > 0) {
|
|
102
|
+
waiters.shift()!.resolve(queue.shift());
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (done) {
|
|
106
|
+
waiters.shift()!.resolve(undefined);
|
|
107
|
+
continue;
|
|
149
108
|
}
|
|
109
|
+
break;
|
|
150
110
|
}
|
|
111
|
+
}
|
|
151
112
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
113
|
+
return {
|
|
114
|
+
push(event: RuntimeEvent) {
|
|
115
|
+
queue.push(event);
|
|
116
|
+
flush();
|
|
117
|
+
},
|
|
118
|
+
end() {
|
|
119
|
+
done = true;
|
|
120
|
+
flush();
|
|
121
|
+
},
|
|
122
|
+
fail(err: unknown) {
|
|
123
|
+
failure = err;
|
|
124
|
+
flush();
|
|
125
|
+
},
|
|
126
|
+
async *stream(): AsyncGenerator<RuntimeEvent> {
|
|
127
|
+
while (true) {
|
|
128
|
+
if (queue.length > 0) {
|
|
129
|
+
yield queue.shift()!;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (failure) throw failure;
|
|
133
|
+
if (done) return;
|
|
134
|
+
|
|
135
|
+
const next = await new Promise<RuntimeEvent | undefined>((resolve, reject) => {
|
|
136
|
+
waiters.push({ resolve, reject });
|
|
168
137
|
});
|
|
169
|
-
|
|
170
|
-
|
|
138
|
+
if (!next) return;
|
|
139
|
+
yield next;
|
|
171
140
|
}
|
|
172
|
-
}
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
173
144
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
145
|
+
function createOpenClawDispatchAdapter(opts: {
|
|
146
|
+
core: PluginRuntime;
|
|
147
|
+
cfg: Record<string, unknown>;
|
|
148
|
+
accountId: string;
|
|
149
|
+
sessionsDir: string;
|
|
150
|
+
}): DispatchAdapter {
|
|
151
|
+
return {
|
|
152
|
+
async *dispatch({ event, earlierEvents = [], bodyForAgent, sessionKey }) {
|
|
153
|
+
const stream = createRuntimeEventStream();
|
|
154
|
+
let reasoningBuffer = "";
|
|
155
|
+
let turnGroupKey = "";
|
|
156
|
+
|
|
157
|
+
const run = opts.core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
158
|
+
ctx: buildInboundCtx(opts.core, opts.accountId, event, sessionKey, bodyForAgent, earlierEvents),
|
|
159
|
+
cfg: opts.cfg,
|
|
160
|
+
dispatcherOptions: {
|
|
161
|
+
deliver: async (payload: { text?: string }) => {
|
|
162
|
+
const replyText = payload.text?.trim();
|
|
163
|
+
if (!replyText) return;
|
|
164
|
+
stream.push({
|
|
165
|
+
type: "text",
|
|
166
|
+
text: replyText,
|
|
167
|
+
project: false,
|
|
168
|
+
groupKey: turnGroupKey || undefined,
|
|
192
169
|
});
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
// Generate a new group key per LLM response turn — shared across thinking + tool_call steps
|
|
199
|
-
turnGroupKey = crypto.randomUUID();
|
|
200
|
-
setDispatchGroupKey(sessionKey, turnGroupKey);
|
|
201
|
-
},
|
|
202
|
-
},
|
|
203
|
-
replyOptions: {
|
|
204
|
-
onReasoningStream: (payload: { text?: string }) => {
|
|
205
|
-
reasoningBuffer += payload.text ?? "";
|
|
170
|
+
},
|
|
171
|
+
onReplyStart: () => {
|
|
172
|
+
turnGroupKey = crypto.randomUUID();
|
|
173
|
+
setDispatchGroupKey(sessionKey, turnGroupKey);
|
|
174
|
+
},
|
|
206
175
|
},
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
log?.warn(`parall[${accountId}]: failed to create thinking step: ${String(err)}`);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
reasoningBuffer = "";
|
|
176
|
+
replyOptions: {
|
|
177
|
+
onReasoningStream: (payload: { text?: string }) => {
|
|
178
|
+
reasoningBuffer += payload.text ?? "";
|
|
179
|
+
},
|
|
180
|
+
onReasoningEnd: async () => {
|
|
181
|
+
if (!reasoningBuffer) return;
|
|
182
|
+
stream.push({
|
|
183
|
+
type: "thinking",
|
|
184
|
+
text: reasoningBuffer,
|
|
185
|
+
groupKey: turnGroupKey || undefined,
|
|
186
|
+
});
|
|
187
|
+
reasoningBuffer = "";
|
|
188
|
+
},
|
|
224
189
|
},
|
|
225
|
-
}
|
|
226
|
-
});
|
|
227
|
-
return true;
|
|
228
|
-
} catch (err) {
|
|
229
|
-
log?.error(`parall[${accountId}]: dispatch failed for ${messageId}: ${String(err)}`);
|
|
230
|
-
return false;
|
|
231
|
-
} finally {
|
|
232
|
-
// Transition session back to idle after dispatch completes (or fails)
|
|
233
|
-
if (sessionId) {
|
|
234
|
-
try {
|
|
235
|
-
await client.updateAgentSession(config.org_id, agentUserId, sessionId, { status: "idle" });
|
|
236
|
-
} catch (err) {
|
|
237
|
-
log?.warn(`parall[${accountId}]: failed to set session idle: ${String(err)}`);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
190
|
+
});
|
|
242
191
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
192
|
+
run.then(() => stream.end()).catch((err) => stream.fail(err));
|
|
193
|
+
yield* stream.stream();
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
forkSession({ sessionKey }) {
|
|
197
|
+
const transcriptFile = resolveTranscriptFile(opts.sessionsDir, sessionKey);
|
|
198
|
+
if (!transcriptFile) return null;
|
|
199
|
+
return forkOrchestratorSession({
|
|
200
|
+
orchestratorSessionKey: sessionKey,
|
|
201
|
+
accountId: opts.accountId,
|
|
202
|
+
transcriptFile,
|
|
203
|
+
sessionsDir: opts.sessionsDir,
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
cleanupFork({ fork }) {
|
|
208
|
+
if (typeof fork.sessionFile !== "string") return;
|
|
209
|
+
cleanupForkSession({
|
|
210
|
+
sessionFile: fork.sessionFile,
|
|
211
|
+
sessionKey: fork.sessionKey,
|
|
212
|
+
sessionsDir: opts.sessionsDir,
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
246
217
|
|
|
247
218
|
export const parallGateway: ChannelGatewayAdapter<ResolvedParallAccount> = {
|
|
248
219
|
startAccount: async (ctx) => {
|
|
@@ -259,25 +230,13 @@ export const parallGateway: ChannelGatewayAdapter<ResolvedParallAccount> = {
|
|
|
259
230
|
token: config.api_key,
|
|
260
231
|
});
|
|
261
232
|
|
|
262
|
-
// Resolve agent's own user ID
|
|
263
233
|
const me = await client.getMe();
|
|
264
234
|
const agentUserId = me.id;
|
|
265
235
|
log?.info(`parall[${ctx.accountId}]: authenticated as ${me.display_name} (${agentUserId})`);
|
|
266
236
|
|
|
267
|
-
// Apply platform config (models, defaults)
|
|
268
237
|
const stateDir = process.env.OPENCLAW_STATE_DIR
|
|
269
238
|
|| path.join(process.env.HOME || "/data", ".openclaw");
|
|
270
239
|
const openclawConfigPath = path.join(stateDir, "openclaw.json");
|
|
271
|
-
const previousEnv = {
|
|
272
|
-
PRLL_API_URL: process.env.PRLL_API_URL,
|
|
273
|
-
PRLL_API_KEY: process.env.PRLL_API_KEY,
|
|
274
|
-
PRLL_ORG_ID: process.env.PRLL_ORG_ID,
|
|
275
|
-
PRLL_AGENT_ID: process.env.PRLL_AGENT_ID,
|
|
276
|
-
};
|
|
277
|
-
process.env.PRLL_API_URL = config.parall_url;
|
|
278
|
-
process.env.PRLL_API_KEY = config.api_key;
|
|
279
|
-
process.env.PRLL_ORG_ID = config.org_id;
|
|
280
|
-
process.env.PRLL_AGENT_ID = agentUserId;
|
|
281
240
|
|
|
282
241
|
const configManagerOpts = {
|
|
283
242
|
client,
|
|
@@ -311,496 +270,44 @@ export const parallGateway: ChannelGatewayAdapter<ResolvedParallAccount> = {
|
|
|
311
270
|
log?.warn(`parall[${ctx.accountId}]: wiki helper startup failed: ${String(err)}`);
|
|
312
271
|
}
|
|
313
272
|
|
|
314
|
-
// Connect WebSocket via ticket auth (reconnection enabled by default in ParallWs)
|
|
315
273
|
const wsUrl = resolveWsUrl(account);
|
|
316
274
|
const ws = new ParallWs({
|
|
317
275
|
getTicket: () => client.getWsTicket(),
|
|
318
276
|
wsUrl,
|
|
319
277
|
});
|
|
320
|
-
|
|
321
|
-
// Orchestrator session key — single session for all Parall events
|
|
322
278
|
const orchestratorKey = buildOrchestratorSessionKey(ctx.accountId);
|
|
323
|
-
|
|
324
|
-
// Chat info lookup — populated on hello, used for structured event bodies
|
|
325
|
-
const chatInfoMap = new Map<string, ChatInfo>();
|
|
326
|
-
|
|
327
|
-
// Session ID from hello — used for heartbeat
|
|
328
|
-
let sessionId = "";
|
|
329
|
-
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
330
|
-
// Typing indicator management — reference-counted per chat
|
|
331
|
-
const activeDispatches = new Map<string, { count: number; typingTimer: ReturnType<typeof setInterval> }>();
|
|
332
|
-
// Dedupe task dispatches
|
|
333
|
-
const dispatchedTasks = new Set<string>();
|
|
334
|
-
// Dedupe message dispatches — shared by live handler and catch-up.
|
|
335
|
-
// Bounded: evict oldest entries when cap is reached.
|
|
336
|
-
const dispatchedMessages = new Set<string>();
|
|
337
|
-
const DISPATCHED_MESSAGES_CAP = 5000;
|
|
338
|
-
// Tracks whether the agent has had at least one successful hello in this process.
|
|
339
|
-
let hadSuccessfulHello = false;
|
|
340
|
-
const COLD_START_WINDOW_MS = 5 * 60_000;
|
|
341
|
-
// Heartbeat watchdog
|
|
342
|
-
let lastHeartbeatAt = Date.now();
|
|
343
|
-
|
|
344
|
-
/** Atomically claim a message ID for processing. Returns false if already claimed. */
|
|
345
|
-
function tryClaimMessage(id: string): boolean {
|
|
346
|
-
if (dispatchedMessages.has(id)) return false;
|
|
347
|
-
if (dispatchedMessages.size >= DISPATCHED_MESSAGES_CAP) {
|
|
348
|
-
let toEvict = Math.floor(DISPATCHED_MESSAGES_CAP / 4);
|
|
349
|
-
for (const old of dispatchedMessages) {
|
|
350
|
-
dispatchedMessages.delete(old);
|
|
351
|
-
if (--toEvict <= 0) break;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
dispatchedMessages.add(id);
|
|
355
|
-
return true;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// Sessions dir for fork transcript resolution.
|
|
359
|
-
// Session key "agent:main:parall:..." → agentId is "main" → store at agents/main/sessions/
|
|
360
279
|
const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
mainDispatching: false,
|
|
367
|
-
activeForks: new Map(),
|
|
368
|
-
pendingForkResults: [],
|
|
369
|
-
mainBuffer: [],
|
|
370
|
-
};
|
|
371
|
-
|
|
372
|
-
// ---------------------------------------------------------------------------
|
|
373
|
-
// Per-fork queue state (internal to gateway, not exposed via DispatchState)
|
|
374
|
-
// ---------------------------------------------------------------------------
|
|
375
|
-
type ForkQueueItem = {
|
|
376
|
-
event: ParallEvent;
|
|
377
|
-
resolve: (dispatched: boolean) => void;
|
|
378
|
-
};
|
|
379
|
-
|
|
380
|
-
type ActiveForkState = {
|
|
381
|
-
sessionKey: string;
|
|
382
|
-
sessionFile: string;
|
|
383
|
-
targetId: string;
|
|
384
|
-
queue: ForkQueueItem[];
|
|
385
|
-
processedEvents: ParallEvent[];
|
|
386
|
-
};
|
|
387
|
-
|
|
388
|
-
const forkStates = new Map<string, ActiveForkState>();
|
|
389
|
-
|
|
390
|
-
// ---------------------------------------------------------------------------
|
|
391
|
-
// Typing indicator helpers
|
|
392
|
-
// ---------------------------------------------------------------------------
|
|
393
|
-
function startTyping(chatId: string) {
|
|
394
|
-
const existing = activeDispatches.get(chatId);
|
|
395
|
-
if (existing) {
|
|
396
|
-
existing.count++;
|
|
397
|
-
} else {
|
|
398
|
-
if (ws.state === "connected") ws.sendTyping(chatId, "start");
|
|
399
|
-
const typingRefresh = setInterval(() => {
|
|
400
|
-
if (ws.state === "connected") ws.sendTyping(chatId, "start");
|
|
401
|
-
}, 2000);
|
|
402
|
-
activeDispatches.set(chatId, { count: 1, typingTimer: typingRefresh });
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
function stopTyping(chatId: string) {
|
|
407
|
-
const dispatch = activeDispatches.get(chatId);
|
|
408
|
-
if (!dispatch) return;
|
|
409
|
-
dispatch.count--;
|
|
410
|
-
if (dispatch.count <= 0) {
|
|
411
|
-
clearInterval(dispatch.typingTimer);
|
|
412
|
-
activeDispatches.delete(chatId);
|
|
413
|
-
if (ws.state === "connected") ws.sendTyping(chatId, "stop");
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// ---------------------------------------------------------------------------
|
|
418
|
-
// Fork drain loop — processes queued events on a forked session
|
|
419
|
-
// ---------------------------------------------------------------------------
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* Process all queued events on a fork session with coalescing, then clean up.
|
|
423
|
-
* Fire-and-forget: callers await per-event promises, not this function.
|
|
424
|
-
*
|
|
425
|
-
* Coalescing: instead of dispatching events one-by-one, all queued events are
|
|
426
|
-
* merged into a single prompt and dispatched once — same pattern as drainMainBuffer.
|
|
427
|
-
*
|
|
428
|
-
* Invariant: between the while-loop empty check and cleanup, there is no
|
|
429
|
-
* await, so JS single-threaded execution guarantees no new events can be
|
|
430
|
-
* pushed to the queue between the check and the cleanup.
|
|
431
|
-
*/
|
|
432
|
-
async function runForkDrainLoop(fork: ActiveForkState) {
|
|
433
|
-
try {
|
|
434
|
-
while (fork.queue.length > 0) {
|
|
435
|
-
// Take ALL queued items at once and coalesce via InboundHistory
|
|
436
|
-
const items = fork.queue.splice(0);
|
|
437
|
-
const events = items.map((it) => it.event);
|
|
438
|
-
const last = events[events.length - 1];
|
|
439
|
-
const earlier = events.slice(0, -1);
|
|
440
|
-
if (earlier.length) await createInputStepsForEarlierEvents(earlier);
|
|
441
|
-
const bodyForAgent = buildEventBody(last);
|
|
442
|
-
const inboundHistory = earlier.length ? buildInboundHistory(earlier) : undefined;
|
|
443
|
-
try {
|
|
444
|
-
await runDispatch(last, fork.sessionKey, bodyForAgent, inboundHistory);
|
|
445
|
-
fork.processedEvents.push(...events);
|
|
446
|
-
for (const it of items) it.resolve(true);
|
|
447
|
-
} catch (err) {
|
|
448
|
-
log?.error(`parall[${ctx.accountId}]: fork dispatch failed for ${last.messageId}: ${String(err)}`);
|
|
449
|
-
for (const it of items) it.resolve(false);
|
|
450
|
-
break;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
// Resolve remaining items as not-dispatched (fork stopped after error).
|
|
454
|
-
// These events are NOT acked — dispatch retains them for catch-up replay.
|
|
455
|
-
for (const remaining of fork.queue.splice(0)) {
|
|
456
|
-
remaining.resolve(false);
|
|
457
|
-
}
|
|
458
|
-
} finally {
|
|
459
|
-
// Collect fork result for injection into main session's next turn
|
|
460
|
-
if (fork.processedEvents.length > 0) {
|
|
461
|
-
const first = fork.processedEvents[0];
|
|
462
|
-
dispatchState.pendingForkResults.push({
|
|
463
|
-
forkSessionKey: fork.sessionKey,
|
|
464
|
-
sourceEvent: {
|
|
465
|
-
type: first.type,
|
|
466
|
-
targetId: fork.targetId,
|
|
467
|
-
summary: fork.processedEvents.length === 1
|
|
468
|
-
? `${first.type} from ${first.senderName} in ${first.targetName ?? fork.targetId}`
|
|
469
|
-
: `${fork.processedEvents.length} events in ${first.targetName ?? fork.targetId}`,
|
|
470
|
-
},
|
|
471
|
-
actions: [],
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
// Remove from tracking maps and clean up session files
|
|
475
|
-
forkStates.delete(fork.targetId);
|
|
476
|
-
dispatchState.activeForks.delete(fork.targetId);
|
|
477
|
-
cleanupForkSession({ sessionFile: fork.sessionFile, sessionKey: fork.sessionKey, sessionsDir });
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
// ---------------------------------------------------------------------------
|
|
482
|
-
// Build OpenClaw inbound context
|
|
483
|
-
// ---------------------------------------------------------------------------
|
|
484
|
-
function buildInboundCtx(
|
|
485
|
-
event: ParallEvent,
|
|
486
|
-
sessionKey: string,
|
|
487
|
-
bodyForAgent: string,
|
|
488
|
-
inboundHistory?: Array<{ sender: string; body: string; timestamp?: number }>,
|
|
489
|
-
): Record<string, unknown> {
|
|
490
|
-
return core.channel.reply.finalizeInboundContext({
|
|
491
|
-
Body: event.body,
|
|
492
|
-
BodyForAgent: bodyForAgent,
|
|
493
|
-
RawBody: event.body,
|
|
494
|
-
CommandBody: event.body,
|
|
495
|
-
...(event.mediaFields ?? {}),
|
|
496
|
-
...(inboundHistory?.length ? { InboundHistory: inboundHistory } : {}),
|
|
497
|
-
From: `parall:${event.senderId}`,
|
|
498
|
-
To: `parall:orchestrator`,
|
|
499
|
-
SessionKey: sessionKey,
|
|
500
|
-
AccountId: ctx.accountId,
|
|
501
|
-
ChatType: event.targetType ?? "unknown",
|
|
502
|
-
SenderName: event.senderName,
|
|
503
|
-
SenderId: event.senderId,
|
|
504
|
-
Provider: "parall" as const,
|
|
505
|
-
Surface: "parall" as const,
|
|
506
|
-
MessageSid: event.messageId,
|
|
507
|
-
Timestamp: Date.now(),
|
|
508
|
-
CommandAuthorized: true,
|
|
509
|
-
OriginatingChannel: "parall" as const,
|
|
510
|
-
OriginatingTo: `parall:orchestrator`,
|
|
511
|
-
});
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// ---------------------------------------------------------------------------
|
|
515
|
-
// Input step + InboundHistory helpers for coalesced events
|
|
516
|
-
// ---------------------------------------------------------------------------
|
|
517
|
-
|
|
518
|
-
/** Create input steps for earlier events in a coalesced batch (observability). */
|
|
519
|
-
async function createInputStepsForEarlierEvents(events: ParallEvent[]) {
|
|
520
|
-
const state = getParallAccountState(ctx.accountId);
|
|
521
|
-
const sessionId = state?.activeSessionId;
|
|
522
|
-
if (!sessionId) return;
|
|
523
|
-
await Promise.allSettled(events.map((ev) => {
|
|
524
|
-
const targetType = ev.targetId.startsWith("cht_") ? "chat" : ev.type === "task" ? "task" : "";
|
|
525
|
-
const triggerType = ev.type === "task" ? "task_assign" : "mention";
|
|
526
|
-
const triggerRef = ev.type === "task" ? { task_id: ev.targetId } : { message_id: ev.messageId };
|
|
527
|
-
return client.createAgentStep(config.org_id, agentUserId, sessionId, {
|
|
528
|
-
step_type: "input",
|
|
529
|
-
target_type: targetType,
|
|
530
|
-
target_id: ev.targetId.startsWith("cht_") ? ev.targetId : ev.type === "task" ? ev.targetId : undefined,
|
|
531
|
-
content: {
|
|
532
|
-
trigger_type: triggerType,
|
|
533
|
-
trigger_ref: triggerRef,
|
|
534
|
-
sender_id: ev.senderId,
|
|
535
|
-
sender_name: ev.senderName,
|
|
536
|
-
summary: ev.body.substring(0, 200),
|
|
537
|
-
},
|
|
538
|
-
}).catch((err: unknown) => {
|
|
539
|
-
log?.warn(`parall[${ctx.accountId}]: failed to create input step for ${ev.messageId}: ${String(err)}`);
|
|
540
|
-
});
|
|
541
|
-
}));
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
/** Build InboundHistory array from earlier events (for OpenClaw native context injection). */
|
|
545
|
-
function buildInboundHistory(events: ParallEvent[]): Array<{ sender: string; body: string }> {
|
|
546
|
-
return events.map((ev) => {
|
|
547
|
-
// Include metadata that would otherwise be lost in the plain body
|
|
548
|
-
const meta: string[] = [];
|
|
549
|
-
meta.push(`[Message ID: ${ev.messageId}]`);
|
|
550
|
-
if (ev.noReply) meta.push(`[Hint: no_reply]`);
|
|
551
|
-
if (ev.threadRootId) meta.push(`[Thread: ${ev.threadRootId}]`);
|
|
552
|
-
if (ev.mediaFields?.MediaUrl) {
|
|
553
|
-
meta.push(`[Attachment: ${ev.mediaFields.MediaType ?? "file"} ${ev.mediaFields.MediaUrl}]`);
|
|
554
|
-
}
|
|
555
|
-
return {
|
|
556
|
-
sender: ev.senderName,
|
|
557
|
-
body: meta.length ? `${meta.join(" ")}\n${ev.body}` : ev.body,
|
|
558
|
-
};
|
|
559
|
-
});
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
// ---------------------------------------------------------------------------
|
|
563
|
-
// Core dispatch: route event to main or fork session
|
|
564
|
-
// ---------------------------------------------------------------------------
|
|
565
|
-
async function runDispatch(
|
|
566
|
-
event: ParallEvent,
|
|
567
|
-
sessionKey: string,
|
|
568
|
-
bodyForAgent: string,
|
|
569
|
-
inboundHistory?: Array<{ sender: string; body: string; timestamp?: number }>,
|
|
570
|
-
) {
|
|
571
|
-
// Set provenance maps for wiki tools
|
|
572
|
-
setSessionChatId(sessionKey, event.targetId);
|
|
573
|
-
setSessionMessageId(sessionKey, event.messageId);
|
|
574
|
-
setDispatchMessageId(sessionKey, event.messageId);
|
|
575
|
-
|
|
576
|
-
const inboundCtx = buildInboundCtx(event, sessionKey, bodyForAgent, inboundHistory);
|
|
577
|
-
const triggerType = event.type === "task" ? "task_assign" : "mention";
|
|
578
|
-
const triggerRef = event.type === "task"
|
|
579
|
-
? { task_id: event.targetId }
|
|
580
|
-
: { message_id: event.messageId };
|
|
581
|
-
const isTask = event.type === "task";
|
|
582
|
-
|
|
583
|
-
const ok = await dispatchToAgent({
|
|
584
|
-
core,
|
|
585
|
-
cfg: ctx.cfg,
|
|
586
|
-
client,
|
|
587
|
-
config,
|
|
588
|
-
agentUserId,
|
|
589
|
-
accountId: ctx.accountId,
|
|
590
|
-
sessionKey,
|
|
591
|
-
messageId: event.messageId,
|
|
592
|
-
inboundCtx,
|
|
593
|
-
triggerType,
|
|
594
|
-
triggerRef,
|
|
595
|
-
chatId: event.targetId.startsWith("cht_") ? event.targetId : undefined,
|
|
596
|
-
defaultTargetType: isTask ? "task" : undefined,
|
|
597
|
-
defaultTargetId: isTask ? event.targetId : undefined,
|
|
598
|
-
senderId: event.senderId,
|
|
599
|
-
senderName: event.senderName,
|
|
600
|
-
messageBody: event.body,
|
|
601
|
-
log,
|
|
602
|
-
});
|
|
603
|
-
if (!ok) throw new Error(`dispatch failed for ${event.messageId}`);
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
/**
|
|
607
|
-
* Drain buffered events into main session with coalescing.
|
|
608
|
-
* When multiple events accumulate while main is busy, they are merged into a
|
|
609
|
-
* single combined prompt and dispatched once (one AgentRun), so the agent sees
|
|
610
|
-
* the full context instead of replying to stale messages one by one.
|
|
611
|
-
*
|
|
612
|
-
* IMPORTANT: Caller must hold mainDispatching=true. This function keeps the flag
|
|
613
|
-
* raised throughout the drain to prevent re-entrance from concurrent event handlers.
|
|
614
|
-
* The flag is only lowered when the buffer is fully exhausted.
|
|
615
|
-
*/
|
|
616
|
-
let draining = false;
|
|
617
|
-
async function drainMainBuffer() {
|
|
618
|
-
// Re-entrance guard: if we're already draining (e.g. fork completed during
|
|
619
|
-
// a drain iteration), the outer loop will pick up new items naturally.
|
|
620
|
-
if (draining) return;
|
|
621
|
-
draining = true;
|
|
622
|
-
try {
|
|
623
|
-
while (dispatchState.mainBuffer.length > 0 || dispatchState.pendingForkResults.length > 0) {
|
|
624
|
-
// If we only have fork results but no events, create a synthetic wake-up
|
|
625
|
-
if (dispatchState.mainBuffer.length === 0 && dispatchState.pendingForkResults.length > 0) {
|
|
626
|
-
const forkPrefix = buildForkResultPrefix(dispatchState.pendingForkResults.splice(0));
|
|
627
|
-
const syntheticEvent: ParallEvent = {
|
|
628
|
-
type: "message",
|
|
629
|
-
targetId: "_orchestrator",
|
|
630
|
-
targetType: "system",
|
|
631
|
-
senderId: "system",
|
|
632
|
-
senderName: "system",
|
|
633
|
-
messageId: `synthetic-${Date.now()}`,
|
|
634
|
-
body: "[Orchestrator: fork session(s) completed — review results above]",
|
|
635
|
-
};
|
|
636
|
-
const bodyForAgent = forkPrefix + buildEventBody(syntheticEvent);
|
|
637
|
-
dispatchState.mainCurrentTargetId = undefined;
|
|
638
|
-
await runDispatch(syntheticEvent, orchestratorKey, bodyForAgent);
|
|
639
|
-
continue;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
// Coalesce same-target events; leave other targets for the next iteration.
|
|
643
|
-
// Under backpressure (max forks), the buffer may contain mixed targets —
|
|
644
|
-
// coalescing across targets would couple unrelated provenance/noReply.
|
|
645
|
-
const targetId = dispatchState.mainBuffer[0].targetId;
|
|
646
|
-
const events: ParallEvent[] = [];
|
|
647
|
-
const rest: ParallEvent[] = [];
|
|
648
|
-
for (const ev of dispatchState.mainBuffer.splice(0)) {
|
|
649
|
-
(ev.targetId === targetId ? events : rest).push(ev);
|
|
650
|
-
}
|
|
651
|
-
dispatchState.mainBuffer.push(...rest);
|
|
652
|
-
|
|
653
|
-
const last = events[events.length - 1];
|
|
654
|
-
const earlier = events.slice(0, -1);
|
|
655
|
-
|
|
656
|
-
// Create input steps for earlier events (observability), then dispatch
|
|
657
|
-
// last event with InboundHistory populated from earlier events.
|
|
658
|
-
if (earlier.length) await createInputStepsForEarlierEvents(earlier);
|
|
659
|
-
|
|
660
|
-
const forkPrefix = buildForkResultPrefix(dispatchState.pendingForkResults.splice(0));
|
|
661
|
-
const bodyForAgent = forkPrefix + buildEventBody(last);
|
|
662
|
-
const inboundHistory = earlier.length ? buildInboundHistory(earlier) : undefined;
|
|
663
|
-
|
|
664
|
-
dispatchState.mainCurrentTargetId = last.targetId;
|
|
665
|
-
await runDispatch(last, orchestratorKey, bodyForAgent, inboundHistory);
|
|
666
|
-
// Ack all coalesced events
|
|
667
|
-
for (const ev of events) {
|
|
668
|
-
const sourceType = ev.type === "task" ? "task_activity" : "message";
|
|
669
|
-
client.ackDispatch(config.org_id, { source_type: sourceType, source_id: ev.messageId }).catch(() => {});
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
} finally {
|
|
673
|
-
draining = false;
|
|
674
|
-
dispatchState.mainDispatching = false;
|
|
675
|
-
dispatchState.mainCurrentTargetId = undefined;
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
/**
|
|
680
|
-
* Route and dispatch an inbound event.
|
|
681
|
-
* Returns true if the event was dispatched (main or fork), false if only buffered.
|
|
682
|
-
* Callers should only ack dispatch when this returns true — buffered events may be
|
|
683
|
-
* lost on crash and need to be replayed from dispatch on the next catch-up.
|
|
684
|
-
*/
|
|
685
|
-
async function handleInboundEvent(event: ParallEvent): Promise<boolean> {
|
|
686
|
-
const disposition = routeTrigger(event, dispatchState, defaultRoutingStrategy);
|
|
687
|
-
|
|
688
|
-
switch (disposition.action) {
|
|
689
|
-
case "main": {
|
|
690
|
-
// Main session is idle — dispatch directly.
|
|
691
|
-
// Set mainDispatching BEFORE the await and keep it raised through
|
|
692
|
-
// drainMainBuffer() so no concurrent event can enter this case.
|
|
693
|
-
// drainMainBuffer() lowers the flag when the buffer is fully exhausted.
|
|
694
|
-
const forkPrefix = buildForkResultPrefix(dispatchState.pendingForkResults.splice(0));
|
|
695
|
-
const bodyForAgent = forkPrefix + buildEventBody(event);
|
|
696
|
-
|
|
697
|
-
dispatchState.mainDispatching = true;
|
|
698
|
-
dispatchState.mainCurrentTargetId = event.targetId;
|
|
699
|
-
try {
|
|
700
|
-
await runDispatch(event, orchestratorKey, bodyForAgent);
|
|
701
|
-
} finally {
|
|
702
|
-
// Drain any events that buffered while we were dispatching.
|
|
703
|
-
// mainDispatching stays true — drainMainBuffer owns releasing it.
|
|
704
|
-
await drainMainBuffer();
|
|
705
|
-
}
|
|
706
|
-
return true;
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
case "buffer-main":
|
|
710
|
-
dispatchState.mainBuffer.push(event);
|
|
711
|
-
return false;
|
|
712
|
-
|
|
713
|
-
case "buffer-fork": {
|
|
714
|
-
// Push event into the existing fork's queue. The fork drain loop will
|
|
715
|
-
// process it and resolve the promise when done. If the fork was cleaned
|
|
716
|
-
// up between the routing decision and here (race), fallback to buffer-main.
|
|
717
|
-
const activeFork = forkStates.get(event.targetId);
|
|
718
|
-
if (!activeFork) {
|
|
719
|
-
dispatchState.mainBuffer.push(event);
|
|
720
|
-
return false;
|
|
721
|
-
}
|
|
722
|
-
return new Promise<boolean>((resolve) => {
|
|
723
|
-
activeFork.queue.push({ event, resolve });
|
|
724
|
-
});
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
case "new-fork": {
|
|
728
|
-
// Fork the orchestrator's transcript and dispatch in parallel.
|
|
729
|
-
const transcriptFile = resolveTranscriptFile(sessionsDir, orchestratorKey);
|
|
730
|
-
const fork = transcriptFile
|
|
731
|
-
? forkOrchestratorSession({
|
|
732
|
-
orchestratorSessionKey: orchestratorKey,
|
|
733
|
-
accountId: ctx.accountId,
|
|
734
|
-
transcriptFile,
|
|
735
|
-
sessionsDir,
|
|
736
|
-
})
|
|
737
|
-
: null;
|
|
738
|
-
|
|
739
|
-
if (fork) {
|
|
740
|
-
const activeFork: ActiveForkState = {
|
|
741
|
-
sessionKey: fork.sessionKey,
|
|
742
|
-
sessionFile: fork.sessionFile,
|
|
743
|
-
targetId: event.targetId,
|
|
744
|
-
queue: [],
|
|
745
|
-
processedEvents: [],
|
|
746
|
-
};
|
|
747
|
-
forkStates.set(event.targetId, activeFork);
|
|
748
|
-
dispatchState.activeForks.set(event.targetId, fork.sessionKey);
|
|
749
|
-
|
|
750
|
-
// Create promise for the first event, then start the drain loop
|
|
751
|
-
const firstEventPromise = new Promise<boolean>((resolve) => {
|
|
752
|
-
activeFork.queue.push({ event, resolve });
|
|
753
|
-
});
|
|
754
|
-
// Fire-and-forget — drain loop runs concurrently, callers await per-event promises
|
|
755
|
-
runForkDrainLoop(activeFork).catch((err) => {
|
|
756
|
-
log?.error(`parall[${ctx.accountId}]: fork drain loop error: ${String(err)}`);
|
|
757
|
-
});
|
|
758
|
-
return firstEventPromise;
|
|
759
|
-
} else {
|
|
760
|
-
log?.warn(`parall[${ctx.accountId}]: fork failed, buffering event for main session`);
|
|
761
|
-
dispatchState.mainBuffer.push(event);
|
|
762
|
-
return false;
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
return false;
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
// ---------------------------------------------------------------------------
|
|
770
|
-
// WebSocket event handlers
|
|
771
|
-
// ---------------------------------------------------------------------------
|
|
772
|
-
|
|
773
|
-
ws.onStateChange((state) => {
|
|
774
|
-
log?.info(`parall[${ctx.accountId}]: connection state → ${state}`);
|
|
280
|
+
const dispatchAdapter = createOpenClawDispatchAdapter({
|
|
281
|
+
core,
|
|
282
|
+
cfg: ctx.cfg,
|
|
283
|
+
accountId: ctx.accountId,
|
|
284
|
+
sessionsDir,
|
|
775
285
|
});
|
|
776
286
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
} catch (err) {
|
|
798
|
-
log?.warn(`parall[${ctx.accountId}]: failed to create agent session: ${String(err)}`);
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
|
|
287
|
+
const gateway = new ParallAgentGateway({
|
|
288
|
+
accountId: ctx.accountId,
|
|
289
|
+
client,
|
|
290
|
+
ws,
|
|
291
|
+
connectionLabel: wsUrl,
|
|
292
|
+
config: {
|
|
293
|
+
parall_url: config.parall_url,
|
|
294
|
+
api_key: config.api_key,
|
|
295
|
+
org_id: config.org_id,
|
|
296
|
+
},
|
|
297
|
+
agentUserId,
|
|
298
|
+
runtimeType: "openclaw",
|
|
299
|
+
runtimeKey: orchestratorKey,
|
|
300
|
+
runtimeRef: { hostname: os.hostname(), pid: process.pid },
|
|
301
|
+
dispatchAdapter,
|
|
302
|
+
log,
|
|
303
|
+
onConfigUpdate: async () => {
|
|
304
|
+
await fetchAndApplyPlatformConfig(configManagerOpts);
|
|
305
|
+
},
|
|
306
|
+
onSessionReady: async ({ activeSessionId }) => {
|
|
802
307
|
setParallAccountState(ctx.accountId, {
|
|
803
308
|
client,
|
|
309
|
+
apiUrl: config.parall_url,
|
|
310
|
+
apiKey: config.api_key,
|
|
804
311
|
orgId: config.org_id,
|
|
805
312
|
agentUserId,
|
|
806
313
|
activeSessionId,
|
|
@@ -808,415 +315,20 @@ export const parallGateway: ChannelGatewayAdapter<ResolvedParallAccount> = {
|
|
|
808
315
|
ws,
|
|
809
316
|
orchestratorSessionKey: orchestratorKey,
|
|
810
317
|
});
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
const now = Date.now();
|
|
817
|
-
const expectedMs = intervalSec * 1000;
|
|
818
|
-
const drift = now - lastHeartbeatAt - expectedMs;
|
|
819
|
-
if (drift > 15000) {
|
|
820
|
-
log?.warn(`parall[${ctx.accountId}]: heartbeat drift ${drift}ms — event loop may be blocked`);
|
|
821
|
-
}
|
|
822
|
-
lastHeartbeatAt = now;
|
|
823
|
-
if (ws.state !== "connected") return;
|
|
824
|
-
ws.sendAgentHeartbeat(sessionId, {
|
|
825
|
-
hostname: os.hostname(),
|
|
826
|
-
cores: os.cpus().length,
|
|
827
|
-
mem_total: os.totalmem(),
|
|
828
|
-
mem_free: os.freemem(),
|
|
829
|
-
uptime: os.uptime(),
|
|
830
|
-
});
|
|
831
|
-
}, intervalSec * 1000);
|
|
832
|
-
|
|
833
|
-
// Dispatch catch-up: fetch pending dispatch events (FIFO).
|
|
834
|
-
// On cold start, skip items older than COLD_START_WINDOW_MS to avoid
|
|
835
|
-
// replaying historical backlog from a previous process.
|
|
836
|
-
const isFirstHello = !hadSuccessfulHello;
|
|
837
|
-
hadSuccessfulHello = true;
|
|
838
|
-
catchUpFromDispatch(isFirstHello).catch((err) => {
|
|
839
|
-
log?.warn(`parall[${ctx.accountId}]: dispatch catch-up failed: ${String(err)}`);
|
|
840
|
-
});
|
|
841
|
-
} catch (err) {
|
|
842
|
-
log?.error(`parall[${ctx.accountId}]: failed to fetch chats: ${String(err)}`);
|
|
843
|
-
}
|
|
844
|
-
});
|
|
845
|
-
|
|
846
|
-
ws.on("chat.update", (data: ChatUpdateData) => {
|
|
847
|
-
const changes = data.changes as Record<string, unknown> | undefined;
|
|
848
|
-
if (!changes) return;
|
|
849
|
-
const existing = chatInfoMap.get(data.chat_id);
|
|
850
|
-
if (existing) {
|
|
851
|
-
chatInfoMap.set(data.chat_id, {
|
|
852
|
-
...existing,
|
|
853
|
-
...(typeof changes.type === "string" ? { type: changes.type as Chat["type"] } : {}),
|
|
854
|
-
...(typeof changes.name === "string" ? { name: changes.name } : {}),
|
|
855
|
-
...(typeof changes.agent_routing_mode === "string" ? { agentRoutingMode: changes.agent_routing_mode as Chat["agent_routing_mode"] } : {}),
|
|
856
|
-
});
|
|
857
|
-
} else if (typeof changes.type === "string") {
|
|
858
|
-
chatInfoMap.set(data.chat_id, {
|
|
859
|
-
type: changes.type as Chat["type"],
|
|
860
|
-
name: (typeof changes.name === "string" ? changes.name : null),
|
|
861
|
-
agentRoutingMode: (typeof changes.agent_routing_mode === "string" ? changes.agent_routing_mode : "passive") as Chat["agent_routing_mode"],
|
|
862
|
-
});
|
|
863
|
-
}
|
|
864
|
-
});
|
|
865
|
-
|
|
866
|
-
// Handle inbound messages (text + file)
|
|
867
|
-
ws.on("message.new", async (data: MessageNewData) => {
|
|
868
|
-
if (data.sender_id === agentUserId) return;
|
|
869
|
-
if (data.message_type !== "text" && data.message_type !== "file") return;
|
|
870
|
-
// Dedupe: claim this message so catch-up won't re-dispatch it
|
|
871
|
-
if (!tryClaimMessage(data.id)) return;
|
|
872
|
-
|
|
873
|
-
const chatId = data.chat_id;
|
|
874
|
-
|
|
875
|
-
// Resolve chat info — query API for unknown chats
|
|
876
|
-
let chatInfo = chatInfoMap.get(chatId);
|
|
877
|
-
if (!chatInfo) {
|
|
878
|
-
try {
|
|
879
|
-
const chat = await client.getChat(config.org_id, chatId);
|
|
880
|
-
chatInfo = { type: chat.type, name: chat.name ?? null, agentRoutingMode: chat.agent_routing_mode };
|
|
881
|
-
chatInfoMap.set(chatId, chatInfo);
|
|
882
|
-
} catch (err) {
|
|
883
|
-
log?.warn(`parall[${ctx.accountId}]: failed to resolve chat for ${chatId}, skipping: ${String(err)}`);
|
|
884
|
-
return;
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
// Build body text and optional media fields
|
|
889
|
-
let body = "";
|
|
890
|
-
let mediaFields: Record<string, string | undefined> = {};
|
|
891
|
-
|
|
892
|
-
if (data.message_type === "text") {
|
|
893
|
-
const content = data.content as TextContent;
|
|
894
|
-
body = content.text?.trim() ?? "";
|
|
895
|
-
if (!body) return;
|
|
896
|
-
|
|
897
|
-
// In group chats, respond based on agent_routing_mode:
|
|
898
|
-
// - passive (default): only when @mentioned or @all
|
|
899
|
-
// - active: all messages dispatched via live handler
|
|
900
|
-
// - smart: skip live handler — server decides routing via inbox items
|
|
901
|
-
if (chatInfo.type === "group" && chatInfo.agentRoutingMode !== "active") {
|
|
902
|
-
const mentions = content.mentions ?? [];
|
|
903
|
-
const isMentioned = mentions.some(
|
|
904
|
-
(m) => m.user_id === agentUserId || m.user_id === MENTION_ALL_USER_ID,
|
|
905
|
-
);
|
|
906
|
-
if (!isMentioned) {
|
|
907
|
-
// Release claim so catch-up can process this via dispatch events
|
|
908
|
-
dispatchedMessages.delete(data.id);
|
|
909
|
-
return;
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
} else {
|
|
913
|
-
const content = data.content as MediaContent;
|
|
914
|
-
if (chatInfo.type === "group" && chatInfo.agentRoutingMode !== "active") {
|
|
915
|
-
dispatchedMessages.delete(data.id);
|
|
916
|
-
return;
|
|
917
|
-
}
|
|
918
|
-
body = content.caption?.trim() || `[file: ${content.file_name || "attachment"}]`;
|
|
919
|
-
if (content.attachment_id) {
|
|
920
|
-
try {
|
|
921
|
-
const fileRes = await client.getFileUrl(content.attachment_id);
|
|
922
|
-
mediaFields = { MediaUrl: fileRes.url, MediaType: content.mime_type };
|
|
923
|
-
} catch (err) {
|
|
924
|
-
log?.warn(`parall[${ctx.accountId}]: failed to get file URL for ${content.attachment_id}: ${String(err)}`);
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
const event: ParallEvent = {
|
|
930
|
-
type: "message",
|
|
931
|
-
targetId: chatId,
|
|
932
|
-
targetName: chatInfo.name ?? undefined,
|
|
933
|
-
targetType: chatInfo.type,
|
|
934
|
-
senderId: data.sender_id,
|
|
935
|
-
senderName: data.sender?.display_name ?? data.sender_id,
|
|
936
|
-
messageId: data.id,
|
|
937
|
-
body,
|
|
938
|
-
threadRootId: data.thread_root_id ?? undefined,
|
|
939
|
-
noReply: data.hints?.no_reply ?? false,
|
|
940
|
-
mediaFields: Object.keys(mediaFields).length > 0 ? mediaFields : undefined,
|
|
941
|
-
};
|
|
942
|
-
|
|
943
|
-
// Start typing for UX feedback. For buffer-main events (main is busy with
|
|
944
|
-
// same target), typing persists until the buffer drains.
|
|
945
|
-
// For fork events, typing persists until the fork dispatch completes.
|
|
946
|
-
// Both handleInboundEvent paths await their dispatches, so finally works correctly.
|
|
947
|
-
const willDispatch = !dispatchState.mainDispatching || dispatchState.mainCurrentTargetId !== chatId;
|
|
948
|
-
if (willDispatch) startTyping(chatId);
|
|
949
|
-
try {
|
|
950
|
-
const dispatched = await handleInboundEvent(event);
|
|
951
|
-
if (dispatched) {
|
|
952
|
-
client.ackDispatch(config.org_id, { source_type: "message", source_id: data.id }).catch(() => {});
|
|
953
|
-
} else {
|
|
954
|
-
// Not dispatched (buffered or fork failed) — release claim so catch-up can retry
|
|
955
|
-
dispatchedMessages.delete(data.id);
|
|
956
|
-
}
|
|
957
|
-
} catch (err) {
|
|
958
|
-
log?.error(`parall[${ctx.accountId}]: event dispatch failed for ${data.id}: ${String(err)}`);
|
|
959
|
-
dispatchedMessages.delete(data.id);
|
|
960
|
-
} finally {
|
|
961
|
-
if (willDispatch) stopTyping(chatId);
|
|
962
|
-
}
|
|
963
|
-
});
|
|
964
|
-
|
|
965
|
-
// Re-apply platform config on server push
|
|
966
|
-
ws.on("agent_config.update", async (data: AgentConfigUpdateData) => {
|
|
967
|
-
log?.info(`parall[${ctx.accountId}]: config update notification (version=${data.version})`);
|
|
968
|
-
try {
|
|
969
|
-
await fetchAndApplyPlatformConfig(configManagerOpts);
|
|
970
|
-
} catch (err) {
|
|
971
|
-
log?.warn(`parall[${ctx.accountId}]: config update failed: ${String(err)}`);
|
|
972
|
-
}
|
|
973
|
-
});
|
|
974
|
-
|
|
975
|
-
// Task assignment handler
|
|
976
|
-
async function handleTaskAssignment(task: Task): Promise<boolean> {
|
|
977
|
-
const dedupeKey = `${task.id}:${task.updated_at}`;
|
|
978
|
-
if (dispatchedTasks.has(dedupeKey)) {
|
|
979
|
-
log?.info(`parall[${ctx.accountId}]: skipping already-dispatched task ${task.identifier ?? task.id}`);
|
|
980
|
-
return false;
|
|
981
|
-
}
|
|
982
|
-
dispatchedTasks.add(dedupeKey);
|
|
983
|
-
log?.info(`parall[${ctx.accountId}]: task assigned: ${task.identifier ?? task.id} "${task.title}"`);
|
|
984
|
-
|
|
985
|
-
const parts = [`Title: ${task.title}`];
|
|
986
|
-
parts.push(`Status: ${task.status}`, `Priority: ${task.priority}`);
|
|
987
|
-
if (task.description) parts.push("", task.description);
|
|
988
|
-
|
|
989
|
-
const event: ParallEvent = {
|
|
990
|
-
type: "task",
|
|
991
|
-
targetId: task.id,
|
|
992
|
-
targetName: task.identifier ?? undefined,
|
|
993
|
-
targetType: "task",
|
|
994
|
-
senderId: task.creator_id,
|
|
995
|
-
senderName: "system",
|
|
996
|
-
messageId: task.id,
|
|
997
|
-
body: parts.join("\n"),
|
|
998
|
-
};
|
|
999
|
-
|
|
1000
|
-
const dispatched = await handleInboundEvent(event);
|
|
1001
|
-
if (!dispatched) {
|
|
1002
|
-
// Buffered, not yet dispatched — release dedupe so catch-up can retry
|
|
1003
|
-
dispatchedTasks.delete(dedupeKey);
|
|
1004
|
-
}
|
|
1005
|
-
return dispatched;
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
// Server event buffer overflowed — events were lost, trigger full catch-up
|
|
1009
|
-
ws.on("recovery.overflow", () => {
|
|
1010
|
-
log?.warn(`parall[${ctx.accountId}]: recovery.overflow — triggering full catch-up`);
|
|
1011
|
-
catchUpFromDispatch().catch((err) =>
|
|
1012
|
-
log?.warn(`parall[${ctx.accountId}]: overflow catch-up failed: ${String(err)}`));
|
|
1013
|
-
});
|
|
1014
|
-
|
|
1015
|
-
ws.on("task.assigned", async (data: TaskAssignedData) => {
|
|
1016
|
-
if (data.assignee_id !== agentUserId) return;
|
|
1017
|
-
if (data.status !== "todo" && data.status !== "in_progress") return;
|
|
1018
|
-
try {
|
|
1019
|
-
const dispatched = await handleTaskAssignment(data);
|
|
1020
|
-
if (dispatched) {
|
|
1021
|
-
client.ackDispatch(config.org_id, { source_type: "task_activity", source_id: data.id }).catch(() => {});
|
|
1022
|
-
}
|
|
1023
|
-
} catch (err) {
|
|
1024
|
-
log?.error(`parall[${ctx.accountId}]: task dispatch failed for ${data.id}: ${String(err)}`);
|
|
1025
|
-
}
|
|
318
|
+
},
|
|
319
|
+
onBeforeDisconnect: async () => {
|
|
320
|
+
stopWikiHelper?.();
|
|
321
|
+
removeParallAccountState(ctx.accountId);
|
|
322
|
+
},
|
|
1026
323
|
});
|
|
1027
324
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
// Cold-start window: On a fresh process start (not reconnect), events older than
|
|
1033
|
-
// COLD_START_WINDOW_MS are acked without processing. This is an intentional operator
|
|
1034
|
-
// decision: a newly started agent should not replay unbounded historical backlog from
|
|
1035
|
-
// a previous process lifetime. Reconnects (non-cold-start) replay all pending events.
|
|
1036
|
-
//
|
|
1037
|
-
// NOTE: RouteToAgents (active/smart group routing) inserts dispatch rows asynchronously
|
|
1038
|
-
// after the message.new WS event. A race exists where the live ack-by-source fires
|
|
1039
|
-
// before the dispatch row is inserted, leaving an orphan pending row. This is benign:
|
|
1040
|
-
// catch-up will process it on next cycle (duplicate delivery, not data loss).
|
|
1041
|
-
// TODO: resolve by creating dispatch rows before publishing the live event, or by
|
|
1042
|
-
// consuming dispatch.new in the live path.
|
|
1043
|
-
async function catchUpFromDispatch(coldStart = false) {
|
|
1044
|
-
const minAge = coldStart ? Date.now() - COLD_START_WINDOW_MS : 0;
|
|
1045
|
-
let cursor: string | undefined;
|
|
1046
|
-
let processed = 0;
|
|
1047
|
-
let skippedOld = 0;
|
|
1048
|
-
|
|
1049
|
-
do {
|
|
1050
|
-
const page = await client.getDispatch(config.org_id, {
|
|
1051
|
-
limit: 50,
|
|
1052
|
-
cursor,
|
|
1053
|
-
});
|
|
1054
|
-
|
|
1055
|
-
for (const item of page.data ?? []) {
|
|
1056
|
-
if (minAge > 0 && new Date(item.created_at).getTime() < minAge) {
|
|
1057
|
-
client.ackDispatchByID(config.org_id, item.id).catch(() => {});
|
|
1058
|
-
skippedOld++;
|
|
1059
|
-
continue;
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
processed++;
|
|
1063
|
-
try {
|
|
1064
|
-
let dispatched = false;
|
|
1065
|
-
if (item.event_type === "task_assign" && item.task_id) {
|
|
1066
|
-
// Task catch-up: fetch full task and dispatch
|
|
1067
|
-
let task: Awaited<ReturnType<typeof client.getTask>> | null = null;
|
|
1068
|
-
let taskFetchFailed = false;
|
|
1069
|
-
try {
|
|
1070
|
-
task = await client.getTask(config.org_id, item.task_id);
|
|
1071
|
-
} catch (err: unknown) {
|
|
1072
|
-
// Distinguish 404 (task deleted) from transient failures
|
|
1073
|
-
const status = (err as { status?: number })?.status;
|
|
1074
|
-
if (status === 404) {
|
|
1075
|
-
task = null; // task genuinely deleted
|
|
1076
|
-
} else {
|
|
1077
|
-
taskFetchFailed = true;
|
|
1078
|
-
log?.warn(`parall[${ctx.accountId}]: catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1081
|
-
if (taskFetchFailed) {
|
|
1082
|
-
continue; // leave pending for next catch-up cycle
|
|
1083
|
-
}
|
|
1084
|
-
if (task) {
|
|
1085
|
-
// Defense-in-depth (D2): verify task is still assigned to this agent
|
|
1086
|
-
if (task.assignee_id !== agentUserId) {
|
|
1087
|
-
log?.info(`parall[${ctx.accountId}]: skipping stale task dispatch ${item.id} — reassigned to ${task.assignee_id}`);
|
|
1088
|
-
client.ackDispatchByID(config.org_id, item.id).catch(() => {});
|
|
1089
|
-
continue;
|
|
1090
|
-
}
|
|
1091
|
-
dispatched = await handleTaskAssignment(task);
|
|
1092
|
-
} else {
|
|
1093
|
-
dispatched = true; // task genuinely deleted — ack to prevent infinite retry
|
|
1094
|
-
}
|
|
1095
|
-
} else if (item.event_type === "message" && item.source_id && item.chat_id) {
|
|
1096
|
-
// Message catch-up: build ParallEvent from dispatch source pointer
|
|
1097
|
-
if (!tryClaimMessage(item.source_id)) continue;
|
|
1098
|
-
let msg: Awaited<ReturnType<typeof client.getMessage>> | null = null;
|
|
1099
|
-
let msgFetchFailed = false;
|
|
1100
|
-
try {
|
|
1101
|
-
msg = await client.getMessage(item.source_id);
|
|
1102
|
-
} catch (err: unknown) {
|
|
1103
|
-
const status = (err as { status?: number })?.status;
|
|
1104
|
-
if (status === 404) {
|
|
1105
|
-
msg = null; // message genuinely deleted
|
|
1106
|
-
} else {
|
|
1107
|
-
msgFetchFailed = true;
|
|
1108
|
-
log?.warn(`parall[${ctx.accountId}]: catch-up message fetch failed for ${item.source_id}, leaving pending: ${String(err)}`);
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
if (msgFetchFailed) {
|
|
1112
|
-
dispatchedMessages.delete(item.source_id);
|
|
1113
|
-
continue; // leave pending for next catch-up cycle
|
|
1114
|
-
}
|
|
1115
|
-
if (!msg || msg.sender_id === agentUserId) {
|
|
1116
|
-
dispatchedMessages.delete(item.source_id);
|
|
1117
|
-
client.ackDispatchByID(config.org_id, item.id).catch(() => {});
|
|
1118
|
-
continue;
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
let chatInfo = chatInfoMap.get(item.chat_id);
|
|
1122
|
-
if (!chatInfo) {
|
|
1123
|
-
try {
|
|
1124
|
-
const chat = await client.getChat(config.org_id, item.chat_id);
|
|
1125
|
-
chatInfo = { type: chat.type, name: chat.name ?? null, agentRoutingMode: chat.agent_routing_mode };
|
|
1126
|
-
chatInfoMap.set(item.chat_id, chatInfo);
|
|
1127
|
-
} catch {
|
|
1128
|
-
dispatchedMessages.delete(item.source_id);
|
|
1129
|
-
continue;
|
|
1130
|
-
}
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
let body = "";
|
|
1134
|
-
let catchUpMediaFields: Record<string, string | undefined> = {};
|
|
1135
|
-
if (msg.message_type === "text") {
|
|
1136
|
-
body = (msg.content as TextContent).text?.trim() ?? "";
|
|
1137
|
-
} else if (msg.message_type === "file") {
|
|
1138
|
-
const fc = msg.content as MediaContent;
|
|
1139
|
-
body = fc.caption?.trim() || `[file: ${fc.file_name || "attachment"}]`;
|
|
1140
|
-
if (fc.attachment_id) {
|
|
1141
|
-
try {
|
|
1142
|
-
const fileRes = await client.getFileUrl(fc.attachment_id);
|
|
1143
|
-
catchUpMediaFields = { MediaUrl: fileRes.url, MediaType: fc.mime_type };
|
|
1144
|
-
} catch { /* degrade gracefully */ }
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1147
|
-
if (!body) {
|
|
1148
|
-
dispatchedMessages.delete(item.source_id);
|
|
1149
|
-
client.ackDispatchByID(config.org_id, item.id).catch(() => {});
|
|
1150
|
-
continue;
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
const event: ParallEvent = {
|
|
1154
|
-
type: "message",
|
|
1155
|
-
targetId: item.chat_id,
|
|
1156
|
-
targetName: chatInfo.name ?? undefined,
|
|
1157
|
-
targetType: chatInfo.type,
|
|
1158
|
-
senderId: msg.sender_id,
|
|
1159
|
-
senderName: msg.sender?.display_name ?? msg.sender_id,
|
|
1160
|
-
messageId: msg.id,
|
|
1161
|
-
body,
|
|
1162
|
-
threadRootId: msg.thread_root_id ?? undefined,
|
|
1163
|
-
noReply: msg.hints?.no_reply ?? false,
|
|
1164
|
-
mediaFields: Object.keys(catchUpMediaFields).length > 0 ? catchUpMediaFields : undefined,
|
|
1165
|
-
};
|
|
1166
|
-
dispatched = await handleInboundEvent(event);
|
|
1167
|
-
}
|
|
1168
|
-
if (dispatched) {
|
|
1169
|
-
client.ackDispatchByID(config.org_id, item.id).catch(() => {});
|
|
1170
|
-
}
|
|
1171
|
-
} catch (err) {
|
|
1172
|
-
log?.warn(`parall[${ctx.accountId}]: catch-up dispatch ${item.id} (${item.event_type}) failed: ${String(err)}`);
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
cursor = page.has_more ? page.next_cursor : undefined;
|
|
1176
|
-
} while (cursor);
|
|
1177
|
-
|
|
1178
|
-
if (processed > 0 || skippedOld > 0) {
|
|
1179
|
-
log?.info(`parall[${ctx.accountId}]: dispatch catch-up: processed ${processed}, skipped ${skippedOld} old item(s)`);
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
log?.info(`parall[${ctx.accountId}]: connecting to ${wsUrl}...`);
|
|
1184
|
-
await ws.connect();
|
|
1185
|
-
|
|
1186
|
-
// Keep the gateway alive until aborted; clean up before resolving
|
|
1187
|
-
return new Promise<void>((resolve) => {
|
|
1188
|
-
ctx.abortSignal.addEventListener("abort", async () => {
|
|
1189
|
-
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
1190
|
-
for (const [, dispatch] of activeDispatches) {
|
|
1191
|
-
clearInterval(dispatch.typingTimer);
|
|
1192
|
-
}
|
|
1193
|
-
activeDispatches.clear();
|
|
325
|
+
try {
|
|
326
|
+
await gateway.run(ctx.abortSignal);
|
|
327
|
+
} finally {
|
|
328
|
+
if (!ctx.abortSignal.aborted) {
|
|
1194
329
|
stopWikiHelper?.();
|
|
1195
|
-
|
|
1196
|
-
// Complete the agent session
|
|
1197
|
-
const currentState = getParallAccountState(ctx.accountId);
|
|
1198
|
-
if (currentState?.activeSessionId) {
|
|
1199
|
-
try {
|
|
1200
|
-
await client.updateAgentSession(config.org_id, agentUserId, currentState.activeSessionId, {
|
|
1201
|
-
status: "completed",
|
|
1202
|
-
});
|
|
1203
|
-
} catch (err) {
|
|
1204
|
-
log?.warn(`parall[${ctx.accountId}]: failed to complete session: ${String(err)}`);
|
|
1205
|
-
}
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
ws.disconnect();
|
|
1209
330
|
removeParallAccountState(ctx.accountId);
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
delete process.env[key];
|
|
1213
|
-
} else {
|
|
1214
|
-
process.env[key] = value;
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
|
-
log?.info(`parall[${ctx.accountId}]: disconnected`);
|
|
1218
|
-
resolve();
|
|
1219
|
-
});
|
|
1220
|
-
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
1221
333
|
},
|
|
1222
334
|
};
|