@parall/agent-core 1.31.0 → 1.32.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.js +12 -12
- package/dist/dispatch-adapter.d.ts +15 -8
- package/dist/dispatch-adapter.d.ts.map +1 -1
- package/dist/event-format.d.ts +1 -1
- package/dist/event-format.d.ts.map +1 -1
- package/dist/event-format.js +68 -25
- package/dist/gateway-base.d.ts +14 -13
- package/dist/gateway-base.d.ts.map +1 -1
- package/dist/gateway-base.js +650 -313
- package/dist/index.d.ts +15 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -12
- package/dist/internal/attachment-input.d.ts +3 -3
- package/dist/internal/attachment-input.d.ts.map +1 -1
- package/dist/internal/attachment-input.js +61 -58
- package/dist/logger.d.ts +1 -1
- package/dist/platform-config.d.ts +28 -2
- package/dist/platform-config.d.ts.map +1 -1
- package/dist/platform-config.js +42 -11
- package/dist/prompt-fragments.d.ts +1 -1
- package/dist/prompt-fragments.d.ts.map +1 -1
- package/dist/prompt-fragments.js +28 -10
- package/dist/provider-config.d.ts +9 -0
- package/dist/provider-config.d.ts.map +1 -1
- package/dist/provider-config.js +13 -2
- package/dist/routing.d.ts +5 -5
- package/dist/routing.js +6 -6
- package/dist/session-state.d.ts +16 -0
- package/dist/session-state.d.ts.map +1 -1
- package/dist/session-state.js +45 -0
- package/dist/skills/index.d.ts +5 -4
- package/dist/skills/index.d.ts.map +1 -1
- package/dist/skills/index.js +28 -21
- package/dist/skills/parall-clips.d.ts +2 -0
- package/dist/skills/parall-clips.d.ts.map +1 -0
- package/dist/skills/parall-clips.js +44 -0
- package/dist/telemetry.d.ts +27 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +205 -0
- package/dist/types.d.ts +18 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +11 -2
- package/src/bridge-workspace.ts +12 -12
- package/src/dispatch-adapter.ts +31 -8
- package/src/event-format.ts +80 -30
- package/src/gateway-base.ts +988 -445
- package/src/index.ts +23 -13
- package/src/internal/attachment-input.ts +127 -100
- package/src/logger.ts +1 -1
- package/src/platform-config.ts +61 -16
- package/src/prompt-fragments.ts +28 -10
- package/src/provider-config.ts +14 -2
- package/src/routing.ts +11 -11
- package/src/session-state.ts +62 -0
- package/src/skills/index.ts +34 -23
- package/src/skills/parall-clips.ts +44 -0
- package/src/telemetry.ts +252 -0
- package/src/types.ts +18 -2
package/dist/gateway-base.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import * as os from
|
|
2
|
-
import * as fs from
|
|
3
|
-
import * as path from
|
|
4
|
-
import { MENTION_ALL_USER_ID } from
|
|
5
|
-
import { buildEventBody, buildEventBodyForForkResult, buildForkResultPrefix, buildForkScopePrefix } from
|
|
6
|
-
import { routeTrigger } from
|
|
7
|
-
import { clearDispatchMessageId, clearDispatchNoReply, clearSessionMessageId, setDispatchMessageId, setDispatchNoReply, setSessionChatId, setSessionMessageId, } from
|
|
8
|
-
|
|
1
|
+
import * as os from 'node:os';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { ApiError, MENTION_ALL_USER_ID } from '@parall/sdk';
|
|
5
|
+
import { buildEventBody, buildEventBodyForForkResult, buildForkResultPrefix, buildForkScopePrefix, } from './event-format.js';
|
|
6
|
+
import { routeTrigger } from './routing.js';
|
|
7
|
+
import { clearDispatchMessageId, clearDispatchMetrics, clearDispatchNoReply, clearSessionMessageId, getDispatchMetrics, recordDeliverText, recordMessageSend, recordNoReply, recordToolCall, resetDispatchMetrics, setDispatchMessageId, setDispatchNoReply, setSessionChatId, setSessionMessageId, } from './session-state.js';
|
|
8
|
+
import { isParallSendCommand, isParallNoReplyCommand, extractShellCommand, } from './bridge-workspace.js';
|
|
9
|
+
import { startDispatchSpan, endDispatchSpan, recordDispatchMetric, recordMissingReply, runWithSessionKey, } from './telemetry.js';
|
|
10
|
+
const LIVE_SESSION_STATUSES = new Set(['open', 'active', 'idle']);
|
|
9
11
|
// Parse PRLL_SHUTDOWN_DEADLINE_MS (or any string env value) into a positive
|
|
10
12
|
// integer milliseconds value, or undefined if unset/invalid. Runtimes pass
|
|
11
13
|
// the result into ParallGatewayOptions.shutdownDeadlineMs; leaving it
|
|
@@ -22,17 +24,72 @@ export function parseShutdownDeadlineMs(raw) {
|
|
|
22
24
|
return undefined;
|
|
23
25
|
return Math.floor(n);
|
|
24
26
|
}
|
|
27
|
+
export function parseForkDeadlineMs(raw) {
|
|
28
|
+
if (!raw)
|
|
29
|
+
return undefined;
|
|
30
|
+
const n = Number(raw);
|
|
31
|
+
if (!Number.isFinite(n) || n <= 0 || n > 2_147_483_647)
|
|
32
|
+
return undefined;
|
|
33
|
+
return Math.floor(n);
|
|
34
|
+
}
|
|
35
|
+
export function parseDispatchDeadlineMs(raw) {
|
|
36
|
+
if (!raw)
|
|
37
|
+
return undefined;
|
|
38
|
+
const n = Number(raw);
|
|
39
|
+
if (!Number.isFinite(n) || n < 0 || n > 2_147_483_647)
|
|
40
|
+
return undefined;
|
|
41
|
+
return Math.floor(n);
|
|
42
|
+
}
|
|
25
43
|
function resolveStepTarget(event) {
|
|
26
|
-
if (event.type ===
|
|
27
|
-
return { target_type:
|
|
44
|
+
if (event.type === 'task' || event.targetId.startsWith('tsk_')) {
|
|
45
|
+
return { target_type: 'task', target_id: event.targetId };
|
|
28
46
|
}
|
|
29
|
-
if (event.targetId.startsWith(
|
|
30
|
-
return { target_type:
|
|
47
|
+
if (event.targetId.startsWith('cht_')) {
|
|
48
|
+
return { target_type: 'chat', target_id: event.targetId };
|
|
31
49
|
}
|
|
32
|
-
if (event.type ===
|
|
33
|
-
return { target_type:
|
|
50
|
+
if (event.type === 'schedule' || event.targetId.startsWith('sch_')) {
|
|
51
|
+
return { target_type: 'schedule', target_id: event.targetId };
|
|
34
52
|
}
|
|
35
|
-
|
|
53
|
+
if (event.type === 'wiki_comment') {
|
|
54
|
+
// target_id is the full wiki target_uri (scheme-stripped routing key). The
|
|
55
|
+
// server stores target_type freely and only publishes step WS events /
|
|
56
|
+
// projects for chat & task, so 'wiki' is an informational tag — no inline
|
|
57
|
+
// wiki step viewer exists yet.
|
|
58
|
+
return { target_type: 'wiki', target_id: event.targetId || undefined };
|
|
59
|
+
}
|
|
60
|
+
return { target_type: '', target_id: event.targetId || undefined };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Minimal parse of a wiki / changeset comment `target_uri` into its routing key
|
|
64
|
+
* and a human label, without depending on `@parall/app`'s full `prll://` parser
|
|
65
|
+
* (agent-core only depends on `@parall/sdk`). Forms:
|
|
66
|
+
* prll://wik_abc/path/to/file.md[?rev=SHA#h=...] → wiki page / inline anchor
|
|
67
|
+
* prll://wcs_xyz?wiki=wik_abc → changeset
|
|
68
|
+
*
|
|
69
|
+
* `routingKey` is the full `target_uri` minus the `prll://` scheme — it is the
|
|
70
|
+
* gateway routing/serialization key, so distinct pages / inline anchors /
|
|
71
|
+
* changesets within the same wiki route as distinct conversations (using the
|
|
72
|
+
* bare `wik_`/`wcs_` entity id would collapse every comment in a wiki onto one
|
|
73
|
+
* lane). Stripping the scheme keeps `prll://${targetId}` correct everywhere it
|
|
74
|
+
* is reconstructed (event body, fork-scope prefix).
|
|
75
|
+
*/
|
|
76
|
+
function parseWikiCommentTarget(targetUri) {
|
|
77
|
+
const routingKey = targetUri.replace(/^prll:\/\//, '');
|
|
78
|
+
const entityId = routingKey.match(/^([^/?#]+)/)?.[1] ?? routingKey;
|
|
79
|
+
const targetType = entityId.startsWith('wcs_') ? 'changeset' : 'wiki';
|
|
80
|
+
let label = entityId;
|
|
81
|
+
if (targetType === 'wiki') {
|
|
82
|
+
const pathMatch = routingKey.slice(entityId.length).match(/^\/([^?#]+)/);
|
|
83
|
+
if (pathMatch) {
|
|
84
|
+
try {
|
|
85
|
+
label = decodeURIComponent(pathMatch[1]);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
label = pathMatch[1];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { routingKey, label, targetType };
|
|
36
93
|
}
|
|
37
94
|
async function fetchAllChats(client, orgId, chatInfoMap) {
|
|
38
95
|
let cursor;
|
|
@@ -54,8 +111,6 @@ async function fetchAllChats(client, orgId, chatInfoMap) {
|
|
|
54
111
|
export class ParallAgentGateway {
|
|
55
112
|
opts;
|
|
56
113
|
chatInfoMap = new Map();
|
|
57
|
-
activeDispatches = new Map();
|
|
58
|
-
injectedTypingCounts = new Map();
|
|
59
114
|
dispatchedTasks = new Set();
|
|
60
115
|
dispatchedMessages = new Set();
|
|
61
116
|
forkStates = new Map();
|
|
@@ -65,11 +120,10 @@ export class ParallAgentGateway {
|
|
|
65
120
|
pendingForkResults: [],
|
|
66
121
|
mainBuffer: [],
|
|
67
122
|
};
|
|
68
|
-
sessionId =
|
|
123
|
+
sessionId = '';
|
|
69
124
|
activeSessionId;
|
|
70
125
|
sessionBindings = new Map();
|
|
71
126
|
heartbeatTimer = null;
|
|
72
|
-
hadSuccessfulHello = false;
|
|
73
127
|
lastHeartbeatAt = Date.now();
|
|
74
128
|
draining = false;
|
|
75
129
|
// Graceful shutdown state. When SIGTERM / abort fires, `shuttingDown` flips
|
|
@@ -81,25 +135,30 @@ export class ParallAgentGateway {
|
|
|
81
135
|
drainResolvers = [];
|
|
82
136
|
pendingRestartNotification = null;
|
|
83
137
|
DISPATCHED_MESSAGES_CAP = 5000;
|
|
84
|
-
COLD_START_WINDOW_MS;
|
|
85
138
|
// SHUTDOWN_DEADLINE_MS is read by waitForDrain via the configured value
|
|
86
139
|
// below — kept as instance state so per-runtime configs can override it
|
|
87
140
|
// (see parseShutdownDeadlineMs and runtime entrypoints).
|
|
88
141
|
SHUTDOWN_DEADLINE_MS;
|
|
142
|
+
FORK_DEADLINE_MS;
|
|
143
|
+
DISPATCH_DEADLINE_MS;
|
|
89
144
|
constructor(opts) {
|
|
90
145
|
this.opts = opts;
|
|
91
|
-
this.COLD_START_WINDOW_MS = opts.coldStartWindowMs ?? 5 * 60_000;
|
|
92
146
|
this.SHUTDOWN_DEADLINE_MS = opts.shutdownDeadlineMs ?? 60_000;
|
|
147
|
+
this.FORK_DEADLINE_MS = opts.forkDeadlineMs ?? 2 * 60 * 60_000;
|
|
148
|
+
this.DISPATCH_DEADLINE_MS = opts.dispatchDeadlineMs ?? 20 * 60_000;
|
|
149
|
+
if (opts.coldStartWindowMs != null) {
|
|
150
|
+
opts.log?.warn?.('coldStartWindowMs is deprecated and ignored — cold-start time filter has been removed');
|
|
151
|
+
}
|
|
93
152
|
}
|
|
94
153
|
async run(abortSignal) {
|
|
95
154
|
const { ws, log } = this.opts;
|
|
96
155
|
ws.onStateChange((state) => {
|
|
97
156
|
log?.info(`connection state → ${state}`);
|
|
98
157
|
});
|
|
99
|
-
ws.on(
|
|
158
|
+
ws.on('hello', async (data) => {
|
|
100
159
|
await this.handleHello(data);
|
|
101
160
|
});
|
|
102
|
-
ws.on(
|
|
161
|
+
ws.on('chat.update', (data) => {
|
|
103
162
|
const changes = data.changes;
|
|
104
163
|
if (!changes)
|
|
105
164
|
return;
|
|
@@ -107,27 +166,27 @@ export class ParallAgentGateway {
|
|
|
107
166
|
if (existing) {
|
|
108
167
|
this.chatInfoMap.set(data.chat_id, {
|
|
109
168
|
...existing,
|
|
110
|
-
...(typeof changes.type ===
|
|
111
|
-
...(typeof changes.name ===
|
|
112
|
-
...(typeof changes.agent_routing_mode ===
|
|
169
|
+
...(typeof changes.type === 'string' ? { type: changes.type } : {}),
|
|
170
|
+
...(typeof changes.name === 'string' ? { name: changes.name } : {}),
|
|
171
|
+
...(typeof changes.agent_routing_mode === 'string'
|
|
113
172
|
? { agentRoutingMode: changes.agent_routing_mode }
|
|
114
173
|
: {}),
|
|
115
174
|
});
|
|
116
175
|
}
|
|
117
|
-
else if (typeof changes.type ===
|
|
176
|
+
else if (typeof changes.type === 'string') {
|
|
118
177
|
this.chatInfoMap.set(data.chat_id, {
|
|
119
178
|
type: changes.type,
|
|
120
|
-
name: typeof changes.name ===
|
|
121
|
-
agentRoutingMode: (typeof changes.agent_routing_mode ===
|
|
179
|
+
name: typeof changes.name === 'string' ? changes.name : null,
|
|
180
|
+
agentRoutingMode: (typeof changes.agent_routing_mode === 'string'
|
|
122
181
|
? changes.agent_routing_mode
|
|
123
|
-
:
|
|
182
|
+
: 'passive'),
|
|
124
183
|
});
|
|
125
184
|
}
|
|
126
185
|
});
|
|
127
|
-
ws.on(
|
|
186
|
+
ws.on('message.new', async (data) => {
|
|
128
187
|
await this.handleMessage(data);
|
|
129
188
|
});
|
|
130
|
-
ws.on(
|
|
189
|
+
ws.on('agent_config.update', async (data) => {
|
|
131
190
|
this.opts.log?.info(`config update notification (version=${data.version})`);
|
|
132
191
|
try {
|
|
133
192
|
await this.opts.onConfigUpdate?.(data);
|
|
@@ -136,13 +195,12 @@ export class ParallAgentGateway {
|
|
|
136
195
|
this.opts.log?.warn(`config update failed: ${String(err)}`);
|
|
137
196
|
}
|
|
138
197
|
});
|
|
139
|
-
ws.on(
|
|
140
|
-
const prevId = data.previous_session_id ??
|
|
198
|
+
ws.on('agent.new_session', async (data) => {
|
|
199
|
+
const prevId = data.previous_session_id ?? '';
|
|
141
200
|
this.opts.log?.info(`new session signal received (previous=${prevId})`);
|
|
142
201
|
this.sessionBindings.clear();
|
|
143
202
|
if (prevId) {
|
|
144
|
-
this.pendingRestartNotification =
|
|
145
|
-
`[Harness Notification] This is a fresh session. Your previous session (${prevId}) was ended by the user and you have been restarted.`;
|
|
203
|
+
this.pendingRestartNotification = `[Harness Notification] This is a fresh session. Your previous session (${prevId}) was ended by the user and you have been restarted.`;
|
|
146
204
|
}
|
|
147
205
|
try {
|
|
148
206
|
await this.opts.onNewSession?.(prevId);
|
|
@@ -151,27 +209,32 @@ export class ParallAgentGateway {
|
|
|
151
209
|
this.opts.log?.warn(`onNewSession callback failed: ${String(err)}`);
|
|
152
210
|
}
|
|
153
211
|
});
|
|
154
|
-
ws.on(
|
|
212
|
+
ws.on('recovery.overflow', () => {
|
|
155
213
|
this.opts.log?.warn(`recovery.overflow — triggering full catch-up`);
|
|
156
214
|
this.catchUpFromDispatch().catch((err) => this.opts.log?.warn(`overflow catch-up failed: ${String(err)}`));
|
|
157
215
|
});
|
|
158
|
-
ws.on(
|
|
216
|
+
ws.on('task.assigned', async (data) => {
|
|
159
217
|
if (data.assignee_id !== this.opts.agentUserId)
|
|
160
218
|
return;
|
|
161
|
-
if (data.status !==
|
|
219
|
+
if (data.status !== 'todo' && data.status !== 'in_progress')
|
|
162
220
|
return;
|
|
163
221
|
try {
|
|
164
222
|
const dispatched = await this.handleTaskAssignment(data, data.id);
|
|
165
223
|
if (dispatched) {
|
|
166
|
-
this.opts.client
|
|
224
|
+
this.opts.client
|
|
225
|
+
.ackDispatch(this.opts.config.org_id, {
|
|
226
|
+
source_type: 'task_activity',
|
|
227
|
+
source_id: data.id,
|
|
228
|
+
})
|
|
229
|
+
.catch(() => { });
|
|
167
230
|
}
|
|
168
231
|
}
|
|
169
232
|
catch (err) {
|
|
170
233
|
this.opts.log?.error(`task dispatch failed for ${data.id}: ${String(err)}`);
|
|
171
234
|
}
|
|
172
235
|
});
|
|
173
|
-
ws.on(
|
|
174
|
-
if (data.event_type ===
|
|
236
|
+
ws.on('dispatch.new', async (data) => {
|
|
237
|
+
if (data.event_type === 'task_comment') {
|
|
175
238
|
if (!data.source_id || !data.task_id)
|
|
176
239
|
return;
|
|
177
240
|
try {
|
|
@@ -184,7 +247,20 @@ export class ParallAgentGateway {
|
|
|
184
247
|
this.opts.log?.error(`task comment dispatch failed for ${data.source_id}: ${String(err)}`);
|
|
185
248
|
}
|
|
186
249
|
}
|
|
187
|
-
else if (data.event_type ===
|
|
250
|
+
else if (data.event_type === 'wiki_comment') {
|
|
251
|
+
if (!data.source_id)
|
|
252
|
+
return;
|
|
253
|
+
try {
|
|
254
|
+
const dispatched = await this.handleWikiComment(data.source_id, data.actor_id, data.delivery_reason);
|
|
255
|
+
if (dispatched) {
|
|
256
|
+
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => { });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
this.opts.log?.error(`wiki comment dispatch failed for ${data.source_id}: ${String(err)}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
else if (data.event_type === 'task_update') {
|
|
188
264
|
if (!data.task_id)
|
|
189
265
|
return;
|
|
190
266
|
try {
|
|
@@ -197,7 +273,7 @@ export class ParallAgentGateway {
|
|
|
197
273
|
this.opts.log?.error(`task update dispatch failed for ${data.task_id}: ${String(err)}`);
|
|
198
274
|
}
|
|
199
275
|
}
|
|
200
|
-
else if (data.event_type ===
|
|
276
|
+
else if (data.event_type === 'schedule.fire') {
|
|
201
277
|
if (!data.source_id)
|
|
202
278
|
return;
|
|
203
279
|
try {
|
|
@@ -210,7 +286,7 @@ export class ParallAgentGateway {
|
|
|
210
286
|
this.opts.log?.error(`schedule fire dispatch failed for ${data.source_id}: ${String(err)}`);
|
|
211
287
|
}
|
|
212
288
|
}
|
|
213
|
-
else if (data.event_type ===
|
|
289
|
+
else if (data.event_type === 'approval_decided') {
|
|
214
290
|
if (!data.source_id)
|
|
215
291
|
return;
|
|
216
292
|
try {
|
|
@@ -223,7 +299,7 @@ export class ParallAgentGateway {
|
|
|
223
299
|
this.opts.log?.error(`approval decided dispatch failed for ${data.source_id}: ${String(err)}`);
|
|
224
300
|
}
|
|
225
301
|
}
|
|
226
|
-
else if (data.event_type !==
|
|
302
|
+
else if (data.event_type !== 'message' && data.event_type !== 'task_assign') {
|
|
227
303
|
// Truly unknown event_type — log so a newly-added dispatch type
|
|
228
304
|
// not yet wired here surfaces during runtime testing. "message"
|
|
229
305
|
// and "task_assign" are deliberately excluded: dispatch.new
|
|
@@ -234,10 +310,10 @@ export class ParallAgentGateway {
|
|
|
234
310
|
this.opts.log?.info(`dispatch.new with unhandled event_type=${String(data.event_type)} (id=${data.id}) — no-op`);
|
|
235
311
|
}
|
|
236
312
|
});
|
|
237
|
-
this.opts.log?.info(`connecting to ${this.opts.connectionLabel ??
|
|
313
|
+
this.opts.log?.info(`connecting to ${this.opts.connectionLabel ?? 'Parall WS'}...`);
|
|
238
314
|
await ws.connect();
|
|
239
315
|
return new Promise((resolve) => {
|
|
240
|
-
abortSignal.addEventListener(
|
|
316
|
+
abortSignal.addEventListener('abort', async () => {
|
|
241
317
|
await this.shutdown();
|
|
242
318
|
resolve();
|
|
243
319
|
});
|
|
@@ -257,60 +333,13 @@ export class ParallAgentGateway {
|
|
|
257
333
|
this.dispatchedMessages.add(id);
|
|
258
334
|
return true;
|
|
259
335
|
}
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
this.opts.ws.sendTyping(chatId, "start");
|
|
268
|
-
const typingRefresh = setInterval(() => {
|
|
269
|
-
if (this.opts.ws.state === "connected")
|
|
270
|
-
this.opts.ws.sendTyping(chatId, "start");
|
|
271
|
-
}, 2000);
|
|
272
|
-
this.activeDispatches.set(chatId, { count: 1, typingTimer: typingRefresh });
|
|
273
|
-
}
|
|
274
|
-
stopTyping(chatId) {
|
|
275
|
-
const dispatch = this.activeDispatches.get(chatId);
|
|
276
|
-
if (!dispatch)
|
|
277
|
-
return;
|
|
278
|
-
dispatch.count--;
|
|
279
|
-
if (dispatch.count > 0)
|
|
280
|
-
return;
|
|
281
|
-
clearInterval(dispatch.typingTimer);
|
|
282
|
-
this.activeDispatches.delete(chatId);
|
|
283
|
-
if (this.opts.ws.state === "connected")
|
|
284
|
-
this.opts.ws.sendTyping(chatId, "stop");
|
|
285
|
-
}
|
|
286
|
-
shouldShowTyping(event) {
|
|
287
|
-
return event.type === "message" && event.targetId.startsWith("cht_") && !event.noReply;
|
|
288
|
-
}
|
|
289
|
-
startInjectedTyping(event) {
|
|
290
|
-
if (!this.shouldShowTyping(event))
|
|
291
|
-
return;
|
|
292
|
-
this.startTyping(event.targetId);
|
|
293
|
-
this.injectedTypingCounts.set(event.targetId, (this.injectedTypingCounts.get(event.targetId) ?? 0) + 1);
|
|
294
|
-
}
|
|
295
|
-
takeInjectedTypingCount(chatId) {
|
|
296
|
-
const count = this.injectedTypingCounts.get(chatId) ?? 0;
|
|
297
|
-
this.injectedTypingCounts.delete(chatId);
|
|
298
|
-
return count;
|
|
299
|
-
}
|
|
300
|
-
async runDispatchWithTyping(event, sessionKey, bodyForAgent, earlierEvents = [], captureText, opts = {}) {
|
|
301
|
-
const showTyping = !opts.suppressStart && (this.shouldShowTyping(event) || earlierEvents.some(e => this.shouldShowTyping(e)));
|
|
302
|
-
if (showTyping)
|
|
303
|
-
this.startTyping(event.targetId);
|
|
304
|
-
try {
|
|
305
|
-
return await this.runDispatch(event, sessionKey, bodyForAgent, earlierEvents, captureText);
|
|
306
|
-
}
|
|
307
|
-
finally {
|
|
308
|
-
if (showTyping)
|
|
309
|
-
this.stopTyping(event.targetId);
|
|
310
|
-
for (let i = 0; i < (opts.injectedTypingCount ?? 0); i++) {
|
|
311
|
-
this.stopTyping(event.targetId);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
336
|
+
async emitDispatchReceived(event) {
|
|
337
|
+
const sourceType = event.ackSourceType ?? (event.type === 'task' ? 'task_activity' : 'message');
|
|
338
|
+
const sourceId = event.ackSourceId ?? event.messageId;
|
|
339
|
+
await this.opts.client.markDispatchReceived(this.opts.config.org_id, {
|
|
340
|
+
source_type: sourceType,
|
|
341
|
+
source_id: sourceId,
|
|
342
|
+
});
|
|
314
343
|
}
|
|
315
344
|
buildDispatchContext(event, sessionKey) {
|
|
316
345
|
const binding = this.sessionBindings.get(sessionKey);
|
|
@@ -323,7 +352,7 @@ export class ParallAgentGateway {
|
|
|
323
352
|
runtimeType: this.opts.runtimeType,
|
|
324
353
|
runtimeKey: this.opts.runtimeKey,
|
|
325
354
|
sessionId: binding?.agentSessionId,
|
|
326
|
-
chatId:
|
|
355
|
+
chatId: event.type === 'message' || event.type === 'approval' ? event.targetId : undefined,
|
|
327
356
|
triggerMessageId: event.messageId,
|
|
328
357
|
noReply: event.noReply ?? false,
|
|
329
358
|
contextFilePath: this.opts.contextFilePathForSession?.(sessionKey),
|
|
@@ -332,24 +361,41 @@ export class ParallAgentGateway {
|
|
|
332
361
|
log: this.opts.log,
|
|
333
362
|
};
|
|
334
363
|
}
|
|
364
|
+
isSessionNotLiveError(err) {
|
|
365
|
+
return (err instanceof ApiError &&
|
|
366
|
+
err.status === 409 &&
|
|
367
|
+
(err.code === 'SESSION_NOT_LIVE' || err.code === 'INVALID_TRANSITION'));
|
|
368
|
+
}
|
|
335
369
|
async createInputStep(sessionId, event) {
|
|
336
370
|
const target = resolveStepTarget(event);
|
|
337
371
|
try {
|
|
338
372
|
await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
|
|
339
|
-
step_type:
|
|
373
|
+
step_type: 'input',
|
|
340
374
|
target_type: target.target_type,
|
|
341
375
|
target_id: target.target_id,
|
|
342
376
|
content: {
|
|
343
|
-
trigger_type: event.type ===
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
377
|
+
trigger_type: event.type === 'task'
|
|
378
|
+
? 'task_assign'
|
|
379
|
+
: event.type === 'task_comment'
|
|
380
|
+
? 'task_comment'
|
|
381
|
+
: event.type === 'wiki_comment'
|
|
382
|
+
? 'wiki_comment'
|
|
383
|
+
: event.type === 'schedule'
|
|
384
|
+
? 'schedule_fire'
|
|
385
|
+
: event.type === 'approval'
|
|
386
|
+
? 'approval_decided'
|
|
387
|
+
: 'mention',
|
|
388
|
+
trigger_ref: event.type === 'task'
|
|
389
|
+
? { task_id: event.targetId }
|
|
390
|
+
: event.type === 'task_comment'
|
|
391
|
+
? { comment_id: event.messageId, task_id: event.targetId }
|
|
392
|
+
: event.type === 'wiki_comment'
|
|
393
|
+
? { comment_id: event.messageId, target_uri: event.replyTargetUri }
|
|
394
|
+
: event.type === 'schedule'
|
|
395
|
+
? { schedule_id: event.targetId, run_id: event.messageId }
|
|
396
|
+
: event.type === 'approval'
|
|
397
|
+
? { approval_id: event.messageId }
|
|
398
|
+
: { message_id: event.messageId },
|
|
353
399
|
sender_id: event.senderId,
|
|
354
400
|
sender_name: event.senderName,
|
|
355
401
|
summary: event.body.substring(0, 200),
|
|
@@ -358,6 +404,8 @@ export class ParallAgentGateway {
|
|
|
358
404
|
});
|
|
359
405
|
}
|
|
360
406
|
catch (err) {
|
|
407
|
+
if (this.isSessionNotLiveError(err))
|
|
408
|
+
throw err;
|
|
361
409
|
this.opts.log?.warn(`failed to create input step: ${String(err)}`);
|
|
362
410
|
}
|
|
363
411
|
}
|
|
@@ -365,18 +413,18 @@ export class ParallAgentGateway {
|
|
|
365
413
|
const target = resolveStepTarget(event);
|
|
366
414
|
try {
|
|
367
415
|
switch (runtimeEvent.type) {
|
|
368
|
-
case
|
|
416
|
+
case 'thinking':
|
|
369
417
|
await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
|
|
370
|
-
step_type:
|
|
418
|
+
step_type: 'thinking',
|
|
371
419
|
target_type: target.target_type,
|
|
372
420
|
target_id: target.target_id,
|
|
373
421
|
content: { text: runtimeEvent.text },
|
|
374
422
|
group_key: runtimeEvent.groupKey,
|
|
375
423
|
});
|
|
376
424
|
break;
|
|
377
|
-
case
|
|
425
|
+
case 'text':
|
|
378
426
|
await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
|
|
379
|
-
step_type:
|
|
427
|
+
step_type: 'text',
|
|
380
428
|
target_type: target.target_type,
|
|
381
429
|
target_id: target.target_id,
|
|
382
430
|
content: {
|
|
@@ -387,16 +435,16 @@ export class ParallAgentGateway {
|
|
|
387
435
|
group_key: runtimeEvent.groupKey,
|
|
388
436
|
});
|
|
389
437
|
break;
|
|
390
|
-
case
|
|
438
|
+
case 'tool_call': {
|
|
391
439
|
const step = await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
|
|
392
|
-
step_type:
|
|
440
|
+
step_type: 'tool_call',
|
|
393
441
|
target_type: target.target_type,
|
|
394
442
|
target_id: target.target_id,
|
|
395
443
|
content: {
|
|
396
444
|
call_id: runtimeEvent.callId,
|
|
397
445
|
tool_name: runtimeEvent.toolName,
|
|
398
446
|
tool_input: runtimeEvent.input,
|
|
399
|
-
status:
|
|
447
|
+
status: 'running',
|
|
400
448
|
started_at: runtimeEvent.startedAt ?? new Date().toISOString(),
|
|
401
449
|
},
|
|
402
450
|
group_key: runtimeEvent.groupKey,
|
|
@@ -410,15 +458,15 @@ export class ParallAgentGateway {
|
|
|
410
458
|
}
|
|
411
459
|
break;
|
|
412
460
|
}
|
|
413
|
-
case
|
|
461
|
+
case 'tool_result':
|
|
414
462
|
await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
|
|
415
|
-
step_type:
|
|
463
|
+
step_type: 'tool_result',
|
|
416
464
|
target_type: target.target_type,
|
|
417
465
|
target_id: target.target_id,
|
|
418
466
|
content: {
|
|
419
467
|
call_id: runtimeEvent.callId,
|
|
420
468
|
tool_name: runtimeEvent.toolName,
|
|
421
|
-
status: runtimeEvent.error ?
|
|
469
|
+
status: runtimeEvent.error ? 'error' : 'success',
|
|
422
470
|
output: runtimeEvent.output,
|
|
423
471
|
duration_ms: runtimeEvent.durationMs ?? 0,
|
|
424
472
|
collapsible: true,
|
|
@@ -432,9 +480,9 @@ export class ParallAgentGateway {
|
|
|
432
480
|
this.clearStepIdFile(stepIdFilePath);
|
|
433
481
|
}
|
|
434
482
|
break;
|
|
435
|
-
case
|
|
483
|
+
case 'error':
|
|
436
484
|
await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
|
|
437
|
-
step_type:
|
|
485
|
+
step_type: 'text',
|
|
438
486
|
target_type: target.target_type,
|
|
439
487
|
target_id: target.target_id,
|
|
440
488
|
content: { text: runtimeEvent.message, suppressed: false },
|
|
@@ -444,13 +492,15 @@ export class ParallAgentGateway {
|
|
|
444
492
|
}
|
|
445
493
|
}
|
|
446
494
|
catch (err) {
|
|
495
|
+
if (this.isSessionNotLiveError(err))
|
|
496
|
+
throw err;
|
|
447
497
|
this.opts.log?.warn(`failed to create ${runtimeEvent.type} step: ${String(err)}`);
|
|
448
498
|
}
|
|
449
499
|
}
|
|
450
500
|
writeContextFile(filePath, ctx) {
|
|
451
501
|
try {
|
|
452
502
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
453
|
-
fs.writeFileSync(filePath, JSON.stringify(ctx),
|
|
503
|
+
fs.writeFileSync(filePath, JSON.stringify(ctx), 'utf8');
|
|
454
504
|
}
|
|
455
505
|
catch (err) {
|
|
456
506
|
this.opts.log?.warn(`failed to write context file ${filePath}: ${String(err)}`);
|
|
@@ -458,10 +508,10 @@ export class ParallAgentGateway {
|
|
|
458
508
|
}
|
|
459
509
|
updateContextFileStepId(filePath, stepId) {
|
|
460
510
|
try {
|
|
461
|
-
const raw = fs.readFileSync(filePath,
|
|
511
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
462
512
|
const ctx = JSON.parse(raw);
|
|
463
513
|
ctx.step_id = stepId;
|
|
464
|
-
fs.writeFileSync(filePath, JSON.stringify(ctx),
|
|
514
|
+
fs.writeFileSync(filePath, JSON.stringify(ctx), 'utf8');
|
|
465
515
|
}
|
|
466
516
|
catch (err) {
|
|
467
517
|
this.opts.log?.warn(`failed to update context file step_id ${filePath}: ${String(err)}`);
|
|
@@ -469,10 +519,10 @@ export class ParallAgentGateway {
|
|
|
469
519
|
}
|
|
470
520
|
updateContextFileSessionId(filePath, sessionId) {
|
|
471
521
|
try {
|
|
472
|
-
const raw = fs.readFileSync(filePath,
|
|
522
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
473
523
|
const ctx = JSON.parse(raw);
|
|
474
524
|
ctx.session_id = sessionId;
|
|
475
|
-
fs.writeFileSync(filePath, JSON.stringify(ctx),
|
|
525
|
+
fs.writeFileSync(filePath, JSON.stringify(ctx), 'utf8');
|
|
476
526
|
}
|
|
477
527
|
catch (err) {
|
|
478
528
|
this.opts.log?.warn(`failed to update context file session_id ${filePath}: ${String(err)}`);
|
|
@@ -482,7 +532,7 @@ export class ParallAgentGateway {
|
|
|
482
532
|
writeStepIdFile(filePath, stepId) {
|
|
483
533
|
try {
|
|
484
534
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
485
|
-
fs.writeFileSync(filePath, stepId,
|
|
535
|
+
fs.writeFileSync(filePath, stepId, 'utf8');
|
|
486
536
|
}
|
|
487
537
|
catch (err) {
|
|
488
538
|
this.opts.log?.warn(`failed to write step id file ${filePath}: ${String(err)}`);
|
|
@@ -491,7 +541,7 @@ export class ParallAgentGateway {
|
|
|
491
541
|
/** @deprecated Use writeContextFile / updateContextFileStepId. */
|
|
492
542
|
clearStepIdFile(filePath) {
|
|
493
543
|
try {
|
|
494
|
-
fs.writeFileSync(filePath,
|
|
544
|
+
fs.writeFileSync(filePath, '', 'utf8');
|
|
495
545
|
}
|
|
496
546
|
catch {
|
|
497
547
|
// Best-effort cleanup.
|
|
@@ -567,118 +617,237 @@ export class ParallAgentGateway {
|
|
|
567
617
|
return false;
|
|
568
618
|
}
|
|
569
619
|
if (this.pendingRestartNotification) {
|
|
570
|
-
bodyForAgent = this.pendingRestartNotification +
|
|
620
|
+
bodyForAgent = this.pendingRestartNotification + '\n\n---\n\n' + bodyForAgent;
|
|
571
621
|
this.pendingRestartNotification = null;
|
|
572
622
|
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
623
|
+
resetDispatchMetrics(sessionKey);
|
|
624
|
+
return runWithSessionKey(sessionKey, async () => {
|
|
625
|
+
let dispatchSpan = null;
|
|
626
|
+
setSessionChatId(sessionKey, event.targetId);
|
|
627
|
+
setSessionMessageId(sessionKey, event.messageId);
|
|
628
|
+
setDispatchMessageId(sessionKey, event.messageId);
|
|
629
|
+
setDispatchNoReply(sessionKey, event.noReply ?? false);
|
|
630
|
+
const dispatchContext = this.buildDispatchContext(event, sessionKey);
|
|
631
|
+
const contextFilePath = dispatchContext.contextFilePath;
|
|
632
|
+
const stepIdFilePath = dispatchContext.stepIdFilePath;
|
|
633
|
+
if (contextFilePath) {
|
|
634
|
+
this.writeContextFile(contextFilePath, {
|
|
635
|
+
session_id: dispatchContext.sessionId ?? null,
|
|
636
|
+
chat_id: dispatchContext.chatId ?? null,
|
|
637
|
+
trigger_message_id: dispatchContext.triggerMessageId ?? null,
|
|
638
|
+
no_reply: dispatchContext.noReply,
|
|
639
|
+
step_id: null,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
// sync: no await between the shuttingDown check above and this increment
|
|
643
|
+
// — JS event loop is single-threaded, so shutdown() cannot interleave
|
|
644
|
+
// here and miss our in-flight count.
|
|
645
|
+
this.inFlightDispatches++;
|
|
646
|
+
const deadlineTimer = this.DISPATCH_DEADLINE_MS > 0
|
|
647
|
+
? setTimeout(() => {
|
|
648
|
+
this.opts.log?.warn(`dispatch deadline exceeded (${this.DISPATCH_DEADLINE_MS}ms) for ${event.messageId} on ${sessionKey}; aborting`);
|
|
649
|
+
try {
|
|
650
|
+
this.opts.dispatchAdapter.abortDispatch?.(sessionKey);
|
|
651
|
+
}
|
|
652
|
+
catch (err) {
|
|
653
|
+
this.opts.log?.warn(`abortDispatch threw for ${sessionKey}: ${String(err)}`);
|
|
654
|
+
}
|
|
655
|
+
}, this.DISPATCH_DEADLINE_MS)
|
|
656
|
+
: null;
|
|
657
|
+
let binding = this.sessionBindings.get(sessionKey);
|
|
658
|
+
let inputStepsCreated = false;
|
|
659
|
+
let triggerMessageSet = false;
|
|
660
|
+
let dispatchError;
|
|
661
|
+
const pendingSendCallIds = new Set();
|
|
662
|
+
try {
|
|
663
|
+
dispatchSpan = startDispatchSpan(event, this.opts.runtimeType, sessionKey);
|
|
664
|
+
for await (const runtimeEvent of this.opts.dispatchAdapter.dispatch({
|
|
665
|
+
event,
|
|
666
|
+
earlierEvents,
|
|
667
|
+
bodyForAgent,
|
|
668
|
+
sessionKey,
|
|
669
|
+
context: dispatchContext,
|
|
670
|
+
})) {
|
|
671
|
+
if (runtimeEvent.type === 'runtime_session') {
|
|
672
|
+
binding = await this.bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath);
|
|
673
|
+
if (!inputStepsCreated) {
|
|
674
|
+
// Persist input steps for "earlier events" (batched events that arrived
|
|
675
|
+
// while a dispatch was in flight) inside the in-flight window so a
|
|
676
|
+
// shutdown short-circuit BEFORE this point cannot leave orphan input
|
|
677
|
+
// steps that the replacement pod would duplicate on replay.
|
|
678
|
+
if (earlierEvents.length > 0) {
|
|
679
|
+
await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
|
|
680
|
+
}
|
|
681
|
+
await this.createInputStep(binding.agentSessionId, event);
|
|
682
|
+
inputStepsCreated = true;
|
|
683
|
+
}
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (!binding) {
|
|
687
|
+
const detail = runtimeEvent.type === 'error' ? `: ${runtimeEvent.message}` : '';
|
|
688
|
+
throw new Error(`runtime emitted ${runtimeEvent.type} before runtime_session${detail}`);
|
|
689
|
+
}
|
|
690
|
+
if (!triggerMessageSet) {
|
|
691
|
+
triggerMessageSet = true;
|
|
692
|
+
this.opts.client
|
|
693
|
+
.updateAgentSession(this.opts.config.org_id, this.opts.agentUserId, binding.agentSessionId, { status: 'active', trigger_message_id: event.messageId })
|
|
694
|
+
.catch((err) => this.opts.log?.warn?.(`failed to set session active: ${err}`));
|
|
695
|
+
}
|
|
606
696
|
if (!inputStepsCreated) {
|
|
607
|
-
// Persist input steps for "earlier events" (batched events that arrived
|
|
608
|
-
// while a dispatch was in flight) inside the in-flight window so a
|
|
609
|
-
// shutdown short-circuit BEFORE this point cannot leave orphan input
|
|
610
|
-
// steps that the replacement pod would duplicate on replay.
|
|
611
697
|
if (earlierEvents.length > 0) {
|
|
612
698
|
await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
|
|
613
699
|
}
|
|
614
700
|
await this.createInputStep(binding.agentSessionId, event);
|
|
615
701
|
inputStepsCreated = true;
|
|
616
702
|
}
|
|
617
|
-
|
|
703
|
+
if (captureText && runtimeEvent.type === 'text' && runtimeEvent.text) {
|
|
704
|
+
captureText.push(runtimeEvent.text);
|
|
705
|
+
}
|
|
706
|
+
if (runtimeEvent.type === 'text' && runtimeEvent.text.length > 0) {
|
|
707
|
+
recordDeliverText(sessionKey, runtimeEvent.text.length);
|
|
708
|
+
}
|
|
709
|
+
else if (runtimeEvent.type === 'tool_call') {
|
|
710
|
+
recordToolCall(sessionKey);
|
|
711
|
+
const cmd = extractShellCommand(runtimeEvent.input);
|
|
712
|
+
if (isParallSendCommand(cmd)) {
|
|
713
|
+
pendingSendCallIds.add(runtimeEvent.callId);
|
|
714
|
+
}
|
|
715
|
+
else if (isParallNoReplyCommand(cmd)) {
|
|
716
|
+
recordNoReply(sessionKey);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
else if (runtimeEvent.type === 'tool_result' &&
|
|
720
|
+
pendingSendCallIds.delete(runtimeEvent.callId)) {
|
|
721
|
+
recordMessageSend(sessionKey, !runtimeEvent.error);
|
|
722
|
+
}
|
|
723
|
+
await this.createRuntimeStep(binding.agentSessionId, event, runtimeEvent, stepIdFilePath, contextFilePath);
|
|
618
724
|
}
|
|
619
725
|
if (!binding) {
|
|
620
|
-
|
|
621
|
-
throw new Error(`runtime emitted ${runtimeEvent.type} before runtime_session${detail}`);
|
|
726
|
+
binding = this.sessionBindings.get(sessionKey);
|
|
622
727
|
}
|
|
623
|
-
if (!
|
|
624
|
-
|
|
625
|
-
this.opts.client.updateAgentSession(this.opts.config.org_id, this.opts.agentUserId, binding.agentSessionId, { status: "active", trigger_message_id: event.messageId }).catch((err) => this.opts.log?.warn?.(`failed to set session active: ${err}`));
|
|
728
|
+
if (!binding) {
|
|
729
|
+
throw new Error('runtime completed without runtime_session');
|
|
626
730
|
}
|
|
627
731
|
if (!inputStepsCreated) {
|
|
628
732
|
if (earlierEvents.length > 0) {
|
|
629
733
|
await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
|
|
630
734
|
}
|
|
631
735
|
await this.createInputStep(binding.agentSessionId, event);
|
|
632
|
-
inputStepsCreated = true;
|
|
633
736
|
}
|
|
634
|
-
if (captureText && runtimeEvent.type === "text" && runtimeEvent.text) {
|
|
635
|
-
captureText.push(runtimeEvent.text);
|
|
636
|
-
}
|
|
637
|
-
await this.createRuntimeStep(binding.agentSessionId, event, runtimeEvent, stepIdFilePath, contextFilePath);
|
|
638
|
-
}
|
|
639
|
-
if (!binding) {
|
|
640
|
-
binding = this.sessionBindings.get(sessionKey);
|
|
641
|
-
}
|
|
642
|
-
if (!binding) {
|
|
643
|
-
throw new Error("runtime completed without runtime_session");
|
|
644
737
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
738
|
+
catch (err) {
|
|
739
|
+
dispatchError = err;
|
|
740
|
+
let staleDetected = this.isSessionNotLiveError(err);
|
|
741
|
+
if (!staleDetected && binding) {
|
|
742
|
+
try {
|
|
743
|
+
await this.createRuntimeStep(binding.agentSessionId, event, {
|
|
744
|
+
type: 'error',
|
|
745
|
+
message: `Dispatch failed: ${String(err)}`,
|
|
746
|
+
}, stepIdFilePath, contextFilePath);
|
|
747
|
+
}
|
|
748
|
+
catch (stepErr) {
|
|
749
|
+
if (this.isSessionNotLiveError(stepErr))
|
|
750
|
+
staleDetected = true;
|
|
751
|
+
}
|
|
648
752
|
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
clearSessionMessageId(sessionKey);
|
|
666
|
-
clearDispatchMessageId(sessionKey);
|
|
667
|
-
clearDispatchNoReply(sessionKey);
|
|
668
|
-
if (contextFilePath) {
|
|
669
|
-
this.updateContextFileStepId(contextFilePath, null);
|
|
670
|
-
}
|
|
671
|
-
else if (stepIdFilePath) {
|
|
672
|
-
this.clearStepIdFile(stepIdFilePath);
|
|
753
|
+
if (staleDetected && binding) {
|
|
754
|
+
this.opts.log?.warn?.(`session ${binding.agentSessionId} is stale (mid-dispatch), triggering recovery for ${sessionKey}`);
|
|
755
|
+
this.sessionBindings.delete(sessionKey);
|
|
756
|
+
if (sessionKey === this.opts.runtimeKey) {
|
|
757
|
+
this.activeSessionId = undefined;
|
|
758
|
+
}
|
|
759
|
+
try {
|
|
760
|
+
await this.opts.onSessionStale?.(sessionKey);
|
|
761
|
+
}
|
|
762
|
+
catch (e) {
|
|
763
|
+
this.opts.log?.warn?.(`onSessionStale failed: ${e}`);
|
|
764
|
+
}
|
|
765
|
+
this.opts.log?.info?.(`stale session self-heal complete for ${sessionKey} — next dispatch will create a fresh session`);
|
|
766
|
+
binding = undefined;
|
|
767
|
+
}
|
|
768
|
+
throw err;
|
|
673
769
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
770
|
+
finally {
|
|
771
|
+
if (deadlineTimer)
|
|
772
|
+
clearTimeout(deadlineTimer);
|
|
773
|
+
const metricsSnapshot = getDispatchMetrics(sessionKey);
|
|
774
|
+
const durationMs = metricsSnapshot ? Date.now() - metricsSnapshot.started_at : 0;
|
|
775
|
+
endDispatchSpan(dispatchSpan, metricsSnapshot, dispatchError);
|
|
776
|
+
recordDispatchMetric(event, this.opts.runtimeType, durationMs);
|
|
777
|
+
if (metricsSnapshot &&
|
|
778
|
+
!dispatchError &&
|
|
779
|
+
event.type === 'message' &&
|
|
780
|
+
event.targetId?.startsWith('cht_') &&
|
|
781
|
+
!event.noReply &&
|
|
782
|
+
metricsSnapshot.deliver_text_chunks > 0 &&
|
|
783
|
+
metricsSnapshot.message_send_successes === 0 &&
|
|
784
|
+
!metricsSnapshot.no_reply_called) {
|
|
785
|
+
recordMissingReply(this.opts.runtimeType);
|
|
786
|
+
}
|
|
787
|
+
clearDispatchMetrics(sessionKey);
|
|
788
|
+
if (triggerMessageSet && binding) {
|
|
789
|
+
this.opts.client
|
|
790
|
+
.updateAgentSession(this.opts.config.org_id, this.opts.agentUserId, binding.agentSessionId, { status: 'idle' })
|
|
791
|
+
.catch((err) => this.opts.log?.warn?.(`failed to set session idle: ${err}`));
|
|
792
|
+
}
|
|
793
|
+
clearSessionMessageId(sessionKey);
|
|
794
|
+
clearDispatchMessageId(sessionKey);
|
|
795
|
+
clearDispatchNoReply(sessionKey);
|
|
796
|
+
if (contextFilePath) {
|
|
797
|
+
this.updateContextFileStepId(contextFilePath, null);
|
|
798
|
+
}
|
|
799
|
+
else if (stepIdFilePath) {
|
|
800
|
+
this.clearStepIdFile(stepIdFilePath);
|
|
801
|
+
}
|
|
802
|
+
this.inFlightDispatches--;
|
|
803
|
+
if (this.inFlightDispatches === 0 && this.drainResolvers.length > 0) {
|
|
804
|
+
const resolvers = this.drainResolvers.splice(0);
|
|
805
|
+
for (const resolve of resolvers)
|
|
806
|
+
resolve();
|
|
807
|
+
}
|
|
679
808
|
}
|
|
809
|
+
return true;
|
|
810
|
+
}); // runWithSessionKey
|
|
811
|
+
}
|
|
812
|
+
abortFork(targetId, reason) {
|
|
813
|
+
const forkState = this.forkStates.get(targetId);
|
|
814
|
+
if (!forkState)
|
|
815
|
+
return;
|
|
816
|
+
this.opts.log?.warn(`aborting fork for ${targetId}: ${reason}`);
|
|
817
|
+
forkState.deadlineExceeded = true;
|
|
818
|
+
if (forkState.deadlineTimer) {
|
|
819
|
+
clearTimeout(forkState.deadlineTimer);
|
|
820
|
+
forkState.deadlineTimer = null;
|
|
821
|
+
}
|
|
822
|
+
this.dispatchState.activeForks.delete(targetId);
|
|
823
|
+
this.forkStates.delete(targetId);
|
|
824
|
+
for (const item of forkState.queue.splice(0)) {
|
|
825
|
+
item.resolve(false);
|
|
826
|
+
}
|
|
827
|
+
if (this.opts.dispatchAdapter.cleanupFork) {
|
|
828
|
+
const forkBinding = this.sessionBindings.get(forkState.fork.sessionKey);
|
|
829
|
+
const cleanupOpts = {
|
|
830
|
+
fork: forkState.fork,
|
|
831
|
+
context: {
|
|
832
|
+
accountId: this.opts.accountId,
|
|
833
|
+
apiUrl: this.opts.config.parall_url,
|
|
834
|
+
apiKey: this.opts.config.api_key,
|
|
835
|
+
orgId: this.opts.config.org_id,
|
|
836
|
+
agentUserId: this.opts.agentUserId,
|
|
837
|
+
runtimeType: this.opts.runtimeType,
|
|
838
|
+
runtimeKey: this.opts.runtimeKey,
|
|
839
|
+
sessionId: forkBinding?.agentSessionId,
|
|
840
|
+
noReply: false,
|
|
841
|
+
client: this.opts.client,
|
|
842
|
+
log: this.opts.log,
|
|
843
|
+
},
|
|
844
|
+
};
|
|
845
|
+
void Promise.resolve()
|
|
846
|
+
.then(() => this.opts.dispatchAdapter.cleanupFork?.(cleanupOpts))
|
|
847
|
+
.catch((err) => {
|
|
848
|
+
this.opts.log?.warn(`abortFork cleanupFork error for ${targetId}: ${String(err)}`);
|
|
849
|
+
});
|
|
680
850
|
}
|
|
681
|
-
return true;
|
|
682
851
|
}
|
|
683
852
|
async runForkDrainLoop(fork) {
|
|
684
853
|
let lastCapturedText = [];
|
|
@@ -690,7 +859,7 @@ export class ParallAgentGateway {
|
|
|
690
859
|
// shuttingDown gate and inFlightDispatches counter, so a shutdown
|
|
691
860
|
// landing mid-batch cannot leave orphan steps that replay would
|
|
692
861
|
// duplicate.
|
|
693
|
-
if (this.shuttingDown) {
|
|
862
|
+
if (this.shuttingDown || fork.deadlineExceeded) {
|
|
694
863
|
for (const item of fork.queue.splice(0))
|
|
695
864
|
item.resolve(false);
|
|
696
865
|
break;
|
|
@@ -701,7 +870,7 @@ export class ParallAgentGateway {
|
|
|
701
870
|
const earlier = events.slice(0, -1);
|
|
702
871
|
try {
|
|
703
872
|
const batchText = [];
|
|
704
|
-
const dispatched = await this.
|
|
873
|
+
const dispatched = await this.runDispatch(last, fork.fork.sessionKey, buildForkScopePrefix(last) + buildEventBody(last), earlier, batchText);
|
|
705
874
|
if (!dispatched) {
|
|
706
875
|
// Shutdown short-circuit — resolve un-acked so the server requeues
|
|
707
876
|
// for the replacement pod and stop draining further items.
|
|
@@ -712,6 +881,11 @@ export class ParallAgentGateway {
|
|
|
712
881
|
}
|
|
713
882
|
if (batchText.length > 0)
|
|
714
883
|
lastCapturedText = batchText;
|
|
884
|
+
if (fork.deadlineExceeded) {
|
|
885
|
+
for (const item of items)
|
|
886
|
+
item.resolve(false);
|
|
887
|
+
break;
|
|
888
|
+
}
|
|
715
889
|
fork.processedEvents.push(...events);
|
|
716
890
|
for (const item of items) {
|
|
717
891
|
item.resolve(true);
|
|
@@ -730,9 +904,13 @@ export class ParallAgentGateway {
|
|
|
730
904
|
}
|
|
731
905
|
}
|
|
732
906
|
finally {
|
|
907
|
+
if (fork.deadlineTimer) {
|
|
908
|
+
clearTimeout(fork.deadlineTimer);
|
|
909
|
+
fork.deadlineTimer = null;
|
|
910
|
+
}
|
|
733
911
|
if (fork.processedEvents.length > 0) {
|
|
734
912
|
const first = fork.processedEvents[0];
|
|
735
|
-
const agentSummary = lastCapturedText.join(
|
|
913
|
+
const agentSummary = lastCapturedText.join('').trim() || undefined;
|
|
736
914
|
let historyPath;
|
|
737
915
|
try {
|
|
738
916
|
historyPath = this.opts.dispatchAdapter.getSessionHistoryPath?.(fork.fork.sessionKey);
|
|
@@ -755,8 +933,12 @@ export class ParallAgentGateway {
|
|
|
755
933
|
historyPath,
|
|
756
934
|
});
|
|
757
935
|
}
|
|
758
|
-
this.forkStates.
|
|
759
|
-
|
|
936
|
+
if (this.forkStates.get(fork.targetId) === fork) {
|
|
937
|
+
this.forkStates.delete(fork.targetId);
|
|
938
|
+
}
|
|
939
|
+
if (this.dispatchState.activeForks.get(fork.targetId) === fork.fork.sessionKey) {
|
|
940
|
+
this.dispatchState.activeForks.delete(fork.targetId);
|
|
941
|
+
}
|
|
760
942
|
const forkBinding = this.sessionBindings.get(fork.fork.sessionKey);
|
|
761
943
|
if (this.opts.dispatchAdapter.cleanupFork) {
|
|
762
944
|
const cleanupOpts = {
|
|
@@ -775,10 +957,17 @@ export class ParallAgentGateway {
|
|
|
775
957
|
log: this.opts.log,
|
|
776
958
|
},
|
|
777
959
|
};
|
|
778
|
-
|
|
960
|
+
try {
|
|
961
|
+
await this.opts.dispatchAdapter.cleanupFork(cleanupOpts);
|
|
962
|
+
}
|
|
963
|
+
catch (err) {
|
|
964
|
+
this.opts.log?.warn(`fork cleanupFork error for ${fork.targetId}: ${String(err)}`);
|
|
965
|
+
}
|
|
779
966
|
}
|
|
780
967
|
if (forkBinding) {
|
|
781
|
-
this.opts.client
|
|
968
|
+
this.opts.client
|
|
969
|
+
.updateAgentSession(this.opts.config.org_id, this.opts.agentUserId, forkBinding.agentSessionId, { status: 'closed' })
|
|
970
|
+
.catch(() => { });
|
|
782
971
|
this.sessionBindings.delete(fork.fork.sessionKey);
|
|
783
972
|
}
|
|
784
973
|
}
|
|
@@ -805,13 +994,22 @@ export class ParallAgentGateway {
|
|
|
805
994
|
const event = events[events.length - 1];
|
|
806
995
|
const earlier = events.slice(0, -1);
|
|
807
996
|
const hasPendingInjections = this.opts.dispatchAdapter.hasPendingInjections?.(this.opts.runtimeKey) ?? false;
|
|
808
|
-
const
|
|
809
|
-
|
|
997
|
+
const pendingFork = hasPendingInjections
|
|
998
|
+
? []
|
|
999
|
+
: this.dispatchState.pendingForkResults.splice(0);
|
|
810
1000
|
const forkPrefix = buildForkResultPrefix(pendingFork);
|
|
811
1001
|
this.dispatchState.mainCurrentTargetId = event.targetId;
|
|
812
|
-
this.dispatchState.mainPreDispatchBranchPoint =
|
|
813
|
-
|
|
814
|
-
|
|
1002
|
+
this.dispatchState.mainPreDispatchBranchPoint = this.opts.dispatchAdapter.getBranchPoint?.(this.opts.runtimeKey);
|
|
1003
|
+
try {
|
|
1004
|
+
await this.emitDispatchReceived(event);
|
|
1005
|
+
}
|
|
1006
|
+
catch (err) {
|
|
1007
|
+
this.opts.log?.warn?.(`mark-received failed for buffered dispatch, leaving unacked for retry: ${String(err)}`);
|
|
1008
|
+
this.dispatchState.mainBuffer.unshift(...events);
|
|
1009
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
const dispatched = await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event), earlier);
|
|
815
1013
|
if (!dispatched) {
|
|
816
1014
|
// Shutdown: skip the ack so the server redelivers these buffered
|
|
817
1015
|
// events to the replacement pod via dispatch catch-up. Put both the
|
|
@@ -821,12 +1019,15 @@ export class ParallAgentGateway {
|
|
|
821
1019
|
break;
|
|
822
1020
|
}
|
|
823
1021
|
for (const bufferedEvent of events) {
|
|
824
|
-
const sourceType = bufferedEvent.ackSourceType ??
|
|
1022
|
+
const sourceType = bufferedEvent.ackSourceType ??
|
|
1023
|
+
(bufferedEvent.type === 'task' ? 'task_activity' : 'message');
|
|
825
1024
|
const sourceId = bufferedEvent.ackSourceId ?? bufferedEvent.messageId;
|
|
826
|
-
this.opts.client
|
|
1025
|
+
this.opts.client
|
|
1026
|
+
.ackDispatch(this.opts.config.org_id, {
|
|
827
1027
|
source_type: sourceType,
|
|
828
1028
|
source_id: sourceId,
|
|
829
|
-
})
|
|
1029
|
+
})
|
|
1030
|
+
.catch(() => { });
|
|
830
1031
|
}
|
|
831
1032
|
}
|
|
832
1033
|
}
|
|
@@ -835,12 +1036,22 @@ export class ParallAgentGateway {
|
|
|
835
1036
|
this.dispatchState.mainDispatching = false;
|
|
836
1037
|
this.dispatchState.mainCurrentTargetId = undefined;
|
|
837
1038
|
this.dispatchState.mainPreDispatchBranchPoint = undefined;
|
|
1039
|
+
if (!this.shuttingDown && this.dispatchState.mainBuffer.length > 0) {
|
|
1040
|
+
setTimeout(() => {
|
|
1041
|
+
if (!this.draining &&
|
|
1042
|
+
!this.dispatchState.mainDispatching &&
|
|
1043
|
+
this.dispatchState.mainBuffer.length > 0) {
|
|
1044
|
+
this.dispatchState.mainDispatching = true;
|
|
1045
|
+
void this.drainMainBuffer();
|
|
1046
|
+
}
|
|
1047
|
+
}, 5000);
|
|
1048
|
+
}
|
|
838
1049
|
}
|
|
839
1050
|
}
|
|
840
1051
|
async handleInboundEvent(event) {
|
|
841
1052
|
const disposition = routeTrigger(event, this.dispatchState);
|
|
842
1053
|
switch (disposition.action) {
|
|
843
|
-
case
|
|
1054
|
+
case 'main': {
|
|
844
1055
|
const pendingFork = this.dispatchState.pendingForkResults.splice(0);
|
|
845
1056
|
const forkPrefix = buildForkResultPrefix(pendingFork);
|
|
846
1057
|
this.dispatchState.mainDispatching = true;
|
|
@@ -848,11 +1059,21 @@ export class ParallAgentGateway {
|
|
|
848
1059
|
// Snapshot the on-disk branch point BEFORE runDispatch starts writing
|
|
849
1060
|
// to the session file. Fork sessions created while main is in-flight
|
|
850
1061
|
// use this to branch from the clean pre-dispatch state.
|
|
851
|
-
this.dispatchState.mainPreDispatchBranchPoint =
|
|
852
|
-
|
|
1062
|
+
this.dispatchState.mainPreDispatchBranchPoint = this.opts.dispatchAdapter.getBranchPoint?.(this.opts.runtimeKey);
|
|
1063
|
+
try {
|
|
1064
|
+
await this.emitDispatchReceived(event);
|
|
1065
|
+
}
|
|
1066
|
+
catch (err) {
|
|
1067
|
+
this.opts.log?.warn?.(`mark-received failed, leaving unacked for retry: ${String(err)}`);
|
|
1068
|
+
this.dispatchState.mainDispatching = false;
|
|
1069
|
+
this.dispatchState.mainCurrentTargetId = undefined;
|
|
1070
|
+
this.dispatchState.mainPreDispatchBranchPoint = undefined;
|
|
1071
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1072
|
+
return false;
|
|
1073
|
+
}
|
|
853
1074
|
let dispatched = false;
|
|
854
1075
|
try {
|
|
855
|
-
dispatched = await this.
|
|
1076
|
+
dispatched = await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event));
|
|
856
1077
|
if (!dispatched) {
|
|
857
1078
|
// Shutdown short-circuit — restore the fork results so a future
|
|
858
1079
|
// pod can replay them, and return false so handleMessage skips ack.
|
|
@@ -864,7 +1085,7 @@ export class ParallAgentGateway {
|
|
|
864
1085
|
}
|
|
865
1086
|
return dispatched;
|
|
866
1087
|
}
|
|
867
|
-
case
|
|
1088
|
+
case 'buffer-main': {
|
|
868
1089
|
if (this.shuttingDown) {
|
|
869
1090
|
return false;
|
|
870
1091
|
}
|
|
@@ -873,20 +1094,21 @@ export class ParallAgentGateway {
|
|
|
873
1094
|
// gap between the steer await and the push.
|
|
874
1095
|
this.dispatchState.mainBuffer.push(event);
|
|
875
1096
|
if (this.dispatchState.mainCurrentTargetId === event.targetId &&
|
|
876
|
-
await this.opts.dispatchAdapter.enqueueDuringDispatch?.(this.opts.runtimeKey, buildEventBody(event))) {
|
|
877
|
-
this.startInjectedTyping(event);
|
|
1097
|
+
(await this.opts.dispatchAdapter.enqueueDuringDispatch?.(this.opts.runtimeKey, buildEventBody(event)))) {
|
|
878
1098
|
this.opts.log?.info(`steer injected for ${event.messageId} (will drain for bookkeeping)`);
|
|
879
1099
|
}
|
|
880
1100
|
// If the main dispatch cycle ended while we awaited the steer RPC,
|
|
881
1101
|
// our event is buffered but no drain is in flight. Re-enter the
|
|
882
1102
|
// drain to process it. The draining guard prevents re-entry.
|
|
883
|
-
if (!this.dispatchState.mainDispatching &&
|
|
1103
|
+
if (!this.dispatchState.mainDispatching &&
|
|
1104
|
+
!this.draining &&
|
|
1105
|
+
this.dispatchState.mainBuffer.length > 0) {
|
|
884
1106
|
this.dispatchState.mainDispatching = true;
|
|
885
1107
|
void this.drainMainBuffer();
|
|
886
1108
|
}
|
|
887
1109
|
return false;
|
|
888
1110
|
}
|
|
889
|
-
case
|
|
1111
|
+
case 'buffer-fork': {
|
|
890
1112
|
const activeFork = this.forkStates.get(event.targetId);
|
|
891
1113
|
if (!activeFork) {
|
|
892
1114
|
this.dispatchState.mainBuffer.push(event);
|
|
@@ -896,11 +1118,19 @@ export class ParallAgentGateway {
|
|
|
896
1118
|
activeFork.queue.push({ event, resolve });
|
|
897
1119
|
});
|
|
898
1120
|
}
|
|
899
|
-
case
|
|
1121
|
+
case 'new-fork': {
|
|
900
1122
|
if (!this.opts.dispatchAdapter.forkSession) {
|
|
901
1123
|
this.dispatchState.mainBuffer.push(event);
|
|
902
1124
|
return false;
|
|
903
1125
|
}
|
|
1126
|
+
try {
|
|
1127
|
+
await this.emitDispatchReceived(event);
|
|
1128
|
+
}
|
|
1129
|
+
catch (err) {
|
|
1130
|
+
this.opts.log?.warn?.(`mark-received failed for fork dispatch, leaving unacked for retry: ${String(err)}`);
|
|
1131
|
+
this.dispatchState.mainBuffer.push(event);
|
|
1132
|
+
return false;
|
|
1133
|
+
}
|
|
904
1134
|
const fork = await this.opts.dispatchAdapter.forkSession({
|
|
905
1135
|
sessionKey: this.opts.runtimeKey,
|
|
906
1136
|
context: this.buildDispatchContext(event, this.opts.runtimeKey),
|
|
@@ -916,9 +1146,14 @@ export class ParallAgentGateway {
|
|
|
916
1146
|
targetId: event.targetId,
|
|
917
1147
|
queue: [],
|
|
918
1148
|
processedEvents: [],
|
|
1149
|
+
deadlineTimer: null,
|
|
1150
|
+
deadlineExceeded: false,
|
|
919
1151
|
};
|
|
920
1152
|
this.forkStates.set(event.targetId, activeFork);
|
|
921
1153
|
this.dispatchState.activeForks.set(event.targetId, fork.sessionKey);
|
|
1154
|
+
activeFork.deadlineTimer = setTimeout(() => {
|
|
1155
|
+
this.abortFork(event.targetId, `deadline exceeded (${this.FORK_DEADLINE_MS}ms)`);
|
|
1156
|
+
}, this.FORK_DEADLINE_MS);
|
|
922
1157
|
const firstEventPromise = new Promise((resolve) => {
|
|
923
1158
|
activeFork.queue.push({ event, resolve });
|
|
924
1159
|
});
|
|
@@ -949,22 +1184,22 @@ export class ParallAgentGateway {
|
|
|
949
1184
|
}
|
|
950
1185
|
}
|
|
951
1186
|
async buildMessageDispatchDecision(chatId, message) {
|
|
952
|
-
if (message.message_type !==
|
|
953
|
-
return { action:
|
|
1187
|
+
if (message.message_type !== 'text') {
|
|
1188
|
+
return { action: 'skip' };
|
|
954
1189
|
}
|
|
955
1190
|
const chatInfo = await this.getOrFetchChatInfo(chatId);
|
|
956
1191
|
if (!chatInfo)
|
|
957
|
-
return { action:
|
|
1192
|
+
return { action: 'retry' };
|
|
958
1193
|
const content = message.content;
|
|
959
|
-
const body = content.text?.trim() ??
|
|
1194
|
+
const body = content.text?.trim() ?? '';
|
|
960
1195
|
const hasAttachments = message.attachments?.length;
|
|
961
1196
|
if (!body && !hasAttachments)
|
|
962
|
-
return { action:
|
|
963
|
-
if (chatInfo.type ===
|
|
1197
|
+
return { action: 'skip' };
|
|
1198
|
+
if (chatInfo.type === 'group' && chatInfo.agentRoutingMode !== 'active') {
|
|
964
1199
|
const mentions = content.mentions ?? [];
|
|
965
1200
|
const isMentioned = mentions.some((mention) => mention.user_id === this.opts.agentUserId || mention.user_id === MENTION_ALL_USER_ID);
|
|
966
1201
|
if (!isMentioned)
|
|
967
|
-
return { action:
|
|
1202
|
+
return { action: 'skip' };
|
|
968
1203
|
}
|
|
969
1204
|
const attachments = (message.attachments ?? []).map((a) => ({
|
|
970
1205
|
id: a.id,
|
|
@@ -972,23 +1207,56 @@ export class ParallAgentGateway {
|
|
|
972
1207
|
fileSize: a.file_size,
|
|
973
1208
|
mimeType: a.mime_type,
|
|
974
1209
|
}));
|
|
1210
|
+
// Fetch unread context (best-effort, parallelized).
|
|
1211
|
+
let unreadCount;
|
|
1212
|
+
let unreadSince;
|
|
1213
|
+
let threadReplyCount;
|
|
1214
|
+
let threadUnreadCount;
|
|
1215
|
+
let threadUnreadSince;
|
|
1216
|
+
const isPassiveOrSmart = chatInfo.type === 'group' && chatInfo.agentRoutingMode !== 'active';
|
|
1217
|
+
const unreadPromise = isPassiveOrSmart
|
|
1218
|
+
? this.opts.client.getUnreadCounts(this.opts.config.org_id).catch(() => undefined)
|
|
1219
|
+
: Promise.resolve(undefined);
|
|
1220
|
+
const threadPromise = message.thread_root_id
|
|
1221
|
+
? this.opts.client
|
|
1222
|
+
.getThreadUnread(this.opts.config.org_id, chatId, message.thread_root_id)
|
|
1223
|
+
.catch(() => undefined)
|
|
1224
|
+
: Promise.resolve(undefined);
|
|
1225
|
+
const [counts, threadUnread] = await Promise.all([unreadPromise, threadPromise]);
|
|
1226
|
+
if (counts) {
|
|
1227
|
+
const entry = counts[chatId];
|
|
1228
|
+
if (entry && entry.count > 1) {
|
|
1229
|
+
unreadCount = entry.count;
|
|
1230
|
+
unreadSince = entry.since;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
if (threadUnread) {
|
|
1234
|
+
threadReplyCount = threadUnread.total;
|
|
1235
|
+
threadUnreadCount = threadUnread.unread > 0 ? threadUnread.unread : undefined;
|
|
1236
|
+
threadUnreadSince = threadUnread.since ?? undefined;
|
|
1237
|
+
}
|
|
975
1238
|
return {
|
|
976
|
-
action:
|
|
1239
|
+
action: 'dispatch',
|
|
977
1240
|
event: {
|
|
978
|
-
type:
|
|
1241
|
+
type: 'message',
|
|
979
1242
|
targetId: chatId,
|
|
980
1243
|
targetName: chatInfo.name ?? undefined,
|
|
981
1244
|
targetType: chatInfo.type,
|
|
982
1245
|
senderId: message.sender_id,
|
|
983
1246
|
senderName: message.sender?.display_name ?? message.sender_id,
|
|
984
1247
|
messageId: message.id,
|
|
985
|
-
body: body ||
|
|
1248
|
+
body: body || '[attachment]',
|
|
986
1249
|
threadRootId: message.thread_root_id ?? undefined,
|
|
987
1250
|
noReply: message.hints?.no_reply ?? false,
|
|
988
1251
|
attachments: attachments.length > 0 ? attachments : undefined,
|
|
989
1252
|
sentAt: message.created_at,
|
|
990
|
-
ackSourceType:
|
|
1253
|
+
ackSourceType: 'message',
|
|
991
1254
|
ackSourceId: message.id,
|
|
1255
|
+
unreadCount,
|
|
1256
|
+
unreadSince,
|
|
1257
|
+
threadReplyCount,
|
|
1258
|
+
threadUnreadCount,
|
|
1259
|
+
threadUnreadSince,
|
|
992
1260
|
},
|
|
993
1261
|
};
|
|
994
1262
|
}
|
|
@@ -998,12 +1266,12 @@ export class ParallAgentGateway {
|
|
|
998
1266
|
const chatId = data.chat_id;
|
|
999
1267
|
if (data.sender_id === this.opts.agentUserId)
|
|
1000
1268
|
return;
|
|
1001
|
-
if (data.message_type !==
|
|
1269
|
+
if (data.message_type !== 'text')
|
|
1002
1270
|
return;
|
|
1003
1271
|
if (!this.tryClaimMessage(data.id))
|
|
1004
1272
|
return;
|
|
1005
1273
|
const decision = await this.buildMessageDispatchDecision(chatId, data);
|
|
1006
|
-
if (decision.action !==
|
|
1274
|
+
if (decision.action !== 'dispatch') {
|
|
1007
1275
|
this.dispatchedMessages.delete(data.id);
|
|
1008
1276
|
return;
|
|
1009
1277
|
}
|
|
@@ -1011,7 +1279,9 @@ export class ParallAgentGateway {
|
|
|
1011
1279
|
try {
|
|
1012
1280
|
const dispatched = await this.handleInboundEvent(event);
|
|
1013
1281
|
if (dispatched) {
|
|
1014
|
-
this.opts.client
|
|
1282
|
+
this.opts.client
|
|
1283
|
+
.ackDispatch(this.opts.config.org_id, { source_type: 'message', source_id: data.id })
|
|
1284
|
+
.catch(() => { });
|
|
1015
1285
|
}
|
|
1016
1286
|
else {
|
|
1017
1287
|
this.dispatchedMessages.delete(data.id);
|
|
@@ -1039,18 +1309,18 @@ export class ParallAgentGateway {
|
|
|
1039
1309
|
if (task.parent_id)
|
|
1040
1310
|
parts.push(`Parent: prll://${task.parent_id}`);
|
|
1041
1311
|
if (task.description)
|
|
1042
|
-
parts.push(
|
|
1312
|
+
parts.push('', task.description);
|
|
1043
1313
|
const event = {
|
|
1044
|
-
type:
|
|
1314
|
+
type: 'task',
|
|
1045
1315
|
targetId: task.id,
|
|
1046
1316
|
targetName: task.identifier ?? undefined,
|
|
1047
|
-
targetType:
|
|
1317
|
+
targetType: 'task',
|
|
1048
1318
|
senderId: task.creator_id,
|
|
1049
|
-
senderName:
|
|
1319
|
+
senderName: 'system',
|
|
1050
1320
|
messageId: task.id,
|
|
1051
|
-
body: parts.join(
|
|
1321
|
+
body: parts.join('\n'),
|
|
1052
1322
|
sentAt: task.updated_at ?? task.created_at,
|
|
1053
|
-
ackSourceType:
|
|
1323
|
+
ackSourceType: 'task_activity',
|
|
1054
1324
|
ackSourceId,
|
|
1055
1325
|
};
|
|
1056
1326
|
const dispatched = await this.handleInboundEvent(event);
|
|
@@ -1113,9 +1383,11 @@ export class ParallAgentGateway {
|
|
|
1113
1383
|
try {
|
|
1114
1384
|
task = await this.opts.client.getTask(this.opts.config.org_id, taskId);
|
|
1115
1385
|
}
|
|
1116
|
-
catch {
|
|
1386
|
+
catch {
|
|
1387
|
+
/* task context is optional */
|
|
1388
|
+
}
|
|
1117
1389
|
const taskLabel = task ? `${task.identifier ?? task.id} "${task.title}"` : taskId;
|
|
1118
|
-
this.opts.log?.info(`task comment on ${taskLabel} by ${actorId ??
|
|
1390
|
+
this.opts.log?.info(`task comment on ${taskLabel} by ${actorId ?? 'unknown'}`);
|
|
1119
1391
|
const parts = [];
|
|
1120
1392
|
if (task) {
|
|
1121
1393
|
parts.push(`Task: ${task.title} (prll://${task.id})`);
|
|
@@ -1124,19 +1396,87 @@ export class ParallAgentGateway {
|
|
|
1124
1396
|
else {
|
|
1125
1397
|
parts.push(`Task: prll://${taskId}`);
|
|
1126
1398
|
}
|
|
1127
|
-
parts.push(`Comment by: ${comment.author?.display_name ?? actorId ??
|
|
1128
|
-
parts.push(
|
|
1399
|
+
parts.push(`Comment by: ${comment.author?.display_name ?? actorId ?? 'unknown'} (prll://${comment.author_id})`);
|
|
1400
|
+
parts.push('', comment.body);
|
|
1129
1401
|
const event = {
|
|
1130
|
-
type:
|
|
1402
|
+
type: 'task_comment',
|
|
1131
1403
|
targetId: taskId,
|
|
1132
1404
|
targetName: task?.identifier ?? undefined,
|
|
1133
|
-
targetType:
|
|
1405
|
+
targetType: 'task',
|
|
1134
1406
|
senderId: comment.author_id,
|
|
1135
|
-
senderName: comment.author?.display_name ?? actorId ??
|
|
1407
|
+
senderName: comment.author?.display_name ?? actorId ?? 'unknown',
|
|
1136
1408
|
messageId: commentId,
|
|
1137
|
-
body: parts.join(
|
|
1409
|
+
body: parts.join('\n'),
|
|
1138
1410
|
deliveryReason: deliveryReason ?? undefined,
|
|
1139
|
-
ackSourceType:
|
|
1411
|
+
ackSourceType: 'comment',
|
|
1412
|
+
ackSourceId: commentId,
|
|
1413
|
+
};
|
|
1414
|
+
let dispatched;
|
|
1415
|
+
try {
|
|
1416
|
+
dispatched = await this.handleInboundEvent(event);
|
|
1417
|
+
}
|
|
1418
|
+
catch (err) {
|
|
1419
|
+
// Clear dedupe key so the event remains retryable on next catch-up.
|
|
1420
|
+
this.dispatchedTasks.delete(dedupeKey);
|
|
1421
|
+
throw err;
|
|
1422
|
+
}
|
|
1423
|
+
if (!dispatched) {
|
|
1424
|
+
this.dispatchedTasks.delete(dedupeKey);
|
|
1425
|
+
}
|
|
1426
|
+
return dispatched;
|
|
1427
|
+
}
|
|
1428
|
+
async handleWikiComment(commentId, actorId, deliveryReason) {
|
|
1429
|
+
if (this.shuttingDown)
|
|
1430
|
+
return false; // drain window — let server requeue via catch-up
|
|
1431
|
+
// Shares the comment dedupe namespace with handleTaskComment; comment IDs
|
|
1432
|
+
// are globally unique so wiki/task keys never collide.
|
|
1433
|
+
const dedupeKey = `comment:${commentId}`;
|
|
1434
|
+
if (this.dispatchedTasks.has(dedupeKey))
|
|
1435
|
+
return false;
|
|
1436
|
+
this.dispatchedTasks.add(dedupeKey);
|
|
1437
|
+
let comment = null;
|
|
1438
|
+
try {
|
|
1439
|
+
comment = await this.opts.client.getComment(this.opts.config.org_id, commentId);
|
|
1440
|
+
}
|
|
1441
|
+
catch (err) {
|
|
1442
|
+
const status = err?.status;
|
|
1443
|
+
// 404 (deleted) and 403 (this agent lacks wiki read access to the
|
|
1444
|
+
// target) are both permanent for this dispatch — ack the stale delivery
|
|
1445
|
+
// so it doesn't retry on every catch-up. Other errors are transient.
|
|
1446
|
+
if (status === 404 || status === 403) {
|
|
1447
|
+
this.opts.log?.info(`skipping inaccessible wiki comment ${commentId} (status ${status}), acking stale dispatch`);
|
|
1448
|
+
this.dispatchedTasks.delete(dedupeKey);
|
|
1449
|
+
return true; // caller will ack
|
|
1450
|
+
}
|
|
1451
|
+
this.dispatchedTasks.delete(dedupeKey);
|
|
1452
|
+
return false; // transient error — leave pending for retry
|
|
1453
|
+
}
|
|
1454
|
+
if (!comment) {
|
|
1455
|
+
this.dispatchedTasks.delete(dedupeKey);
|
|
1456
|
+
return true; // null response = gone, ack stale dispatch
|
|
1457
|
+
}
|
|
1458
|
+
if (comment.hints?.no_reply) {
|
|
1459
|
+
this.opts.log?.info(`skipping no_reply wiki comment ${commentId}, acking stale dispatch`);
|
|
1460
|
+
this.dispatchedTasks.delete(dedupeKey);
|
|
1461
|
+
return true;
|
|
1462
|
+
}
|
|
1463
|
+
const target = parseWikiCommentTarget(comment.target_uri);
|
|
1464
|
+
this.opts.log?.info(`wiki comment on ${target.label} by ${actorId ?? 'unknown'}`);
|
|
1465
|
+
const event = {
|
|
1466
|
+
type: 'wiki_comment',
|
|
1467
|
+
// Full target_uri (scheme-stripped) is the routing key so different
|
|
1468
|
+
// pages / inline anchors / changesets in the same wiki don't collide on
|
|
1469
|
+
// one gateway lane. replyTargetUri keeps the canonical prll:// form.
|
|
1470
|
+
targetId: target.routingKey,
|
|
1471
|
+
targetName: target.label,
|
|
1472
|
+
targetType: target.targetType,
|
|
1473
|
+
senderId: comment.author_id,
|
|
1474
|
+
senderName: comment.author?.display_name ?? actorId ?? 'unknown',
|
|
1475
|
+
messageId: commentId,
|
|
1476
|
+
body: comment.body,
|
|
1477
|
+
deliveryReason: deliveryReason ?? undefined,
|
|
1478
|
+
replyTargetUri: comment.target_uri,
|
|
1479
|
+
ackSourceType: 'comment',
|
|
1140
1480
|
ackSourceId: commentId,
|
|
1141
1481
|
};
|
|
1142
1482
|
let dispatched;
|
|
@@ -1193,20 +1533,20 @@ export class ParallAgentGateway {
|
|
|
1193
1533
|
this.dispatchedTasks.add(dedupeKey);
|
|
1194
1534
|
this.opts.log?.info(`schedule fired: ${run.id} (schedule ${run.schedule_id})`);
|
|
1195
1535
|
const event = {
|
|
1196
|
-
type:
|
|
1536
|
+
type: 'schedule',
|
|
1197
1537
|
// Route by schedule_id (not attached chat_id) so concurrent fires of
|
|
1198
1538
|
// different schedules can fork independently — matches the PR1 primitive
|
|
1199
1539
|
// design where "schedule triggers; target decides response" and fire
|
|
1200
1540
|
// semantics are independent of any attached conversation.
|
|
1201
1541
|
targetId: run.schedule_id,
|
|
1202
|
-
targetType:
|
|
1203
|
-
senderId: actorId ??
|
|
1204
|
-
senderName:
|
|
1542
|
+
targetType: 'schedule',
|
|
1543
|
+
senderId: actorId ?? 'system',
|
|
1544
|
+
senderName: 'schedule',
|
|
1205
1545
|
messageId: run.id,
|
|
1206
|
-
body: run.fired_description ??
|
|
1546
|
+
body: run.fired_description ?? '',
|
|
1207
1547
|
scheduledFireAt: run.scheduled_fire_at,
|
|
1208
1548
|
attachedUri: run.fired_attached_uri ?? undefined,
|
|
1209
|
-
ackSourceType:
|
|
1549
|
+
ackSourceType: 'schedule_run',
|
|
1210
1550
|
ackSourceId: run.id,
|
|
1211
1551
|
};
|
|
1212
1552
|
let dispatched;
|
|
@@ -1245,14 +1585,14 @@ export class ParallAgentGateway {
|
|
|
1245
1585
|
return false;
|
|
1246
1586
|
this.dispatchedTasks.add(dedupeKey);
|
|
1247
1587
|
this.opts.log?.info(`approval decided: ${approval.id} (${approval.status})`);
|
|
1248
|
-
const statusLabel = approval.status ===
|
|
1249
|
-
const execInfo = approval.execution_status ? ` | execution: ${approval.execution_status}` :
|
|
1588
|
+
const statusLabel = approval.status === 'approved' ? 'Approved' : 'Rejected';
|
|
1589
|
+
const execInfo = approval.execution_status ? ` | execution: ${approval.execution_status}` : '';
|
|
1250
1590
|
const body = `${statusLabel}: ${approval.title}${execInfo}`;
|
|
1251
1591
|
const event = {
|
|
1252
|
-
type:
|
|
1592
|
+
type: 'approval',
|
|
1253
1593
|
targetId: chatId ?? approval.chat_id,
|
|
1254
|
-
senderId: actorId ?? approval.decided_by ??
|
|
1255
|
-
senderName:
|
|
1594
|
+
senderId: actorId ?? approval.decided_by ?? 'system',
|
|
1595
|
+
senderName: 'approver',
|
|
1256
1596
|
messageId: approval.id,
|
|
1257
1597
|
body,
|
|
1258
1598
|
};
|
|
@@ -1269,11 +1609,9 @@ export class ParallAgentGateway {
|
|
|
1269
1609
|
}
|
|
1270
1610
|
return dispatched;
|
|
1271
1611
|
}
|
|
1272
|
-
async catchUpFromDispatch(
|
|
1273
|
-
const minAge = coldStart ? Date.now() - this.COLD_START_WINDOW_MS : 0;
|
|
1612
|
+
async catchUpFromDispatch() {
|
|
1274
1613
|
let cursor;
|
|
1275
1614
|
let processed = 0;
|
|
1276
|
-
let skippedOld = 0;
|
|
1277
1615
|
do {
|
|
1278
1616
|
const page = await this.opts.client.getDispatch(this.opts.config.org_id, {
|
|
1279
1617
|
limit: 50,
|
|
@@ -1284,15 +1622,10 @@ export class ParallAgentGateway {
|
|
|
1284
1622
|
// will only reject. Items remain unacked for the replacement pod.
|
|
1285
1623
|
if (this.shuttingDown)
|
|
1286
1624
|
break;
|
|
1287
|
-
if (minAge > 0 && new Date(item.created_at).getTime() < minAge) {
|
|
1288
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
|
|
1289
|
-
skippedOld++;
|
|
1290
|
-
continue;
|
|
1291
|
-
}
|
|
1292
1625
|
processed++;
|
|
1293
1626
|
try {
|
|
1294
1627
|
let dispatched = false;
|
|
1295
|
-
if (item.event_type ===
|
|
1628
|
+
if (item.event_type === 'task_assign' && item.task_id) {
|
|
1296
1629
|
try {
|
|
1297
1630
|
dispatched = await this.handleTaskDispatch(item.task_id, item.source_id ?? item.task_id);
|
|
1298
1631
|
}
|
|
@@ -1301,7 +1634,7 @@ export class ParallAgentGateway {
|
|
|
1301
1634
|
continue;
|
|
1302
1635
|
}
|
|
1303
1636
|
}
|
|
1304
|
-
else if (item.event_type ===
|
|
1637
|
+
else if (item.event_type === 'task_update' && item.task_id) {
|
|
1305
1638
|
try {
|
|
1306
1639
|
dispatched = await this.handleTaskDispatch(item.task_id, item.source_id ?? item.task_id, { allowCreator: true });
|
|
1307
1640
|
}
|
|
@@ -1310,16 +1643,19 @@ export class ParallAgentGateway {
|
|
|
1310
1643
|
continue;
|
|
1311
1644
|
}
|
|
1312
1645
|
}
|
|
1313
|
-
else if (item.event_type ===
|
|
1646
|
+
else if (item.event_type === 'task_comment' && item.source_id && item.task_id) {
|
|
1314
1647
|
dispatched = await this.handleTaskComment(item.source_id, item.task_id, item.actor_id, item.delivery_reason);
|
|
1315
1648
|
}
|
|
1316
|
-
else if (item.event_type ===
|
|
1649
|
+
else if (item.event_type === 'wiki_comment' && item.source_id) {
|
|
1650
|
+
dispatched = await this.handleWikiComment(item.source_id, item.actor_id, item.delivery_reason);
|
|
1651
|
+
}
|
|
1652
|
+
else if (item.event_type === 'schedule.fire' && item.source_id) {
|
|
1317
1653
|
dispatched = await this.fetchAndHandleScheduleFire(item.source_id, item.actor_id);
|
|
1318
1654
|
}
|
|
1319
|
-
else if (item.event_type ===
|
|
1655
|
+
else if (item.event_type === 'approval_decided' && item.source_id) {
|
|
1320
1656
|
dispatched = await this.fetchAndHandleApprovalDecided(item.source_id, item.actor_id, item.chat_id ?? null);
|
|
1321
1657
|
}
|
|
1322
|
-
else if (item.event_type ===
|
|
1658
|
+
else if (item.event_type === 'message' && item.source_id && item.chat_id) {
|
|
1323
1659
|
if (!this.tryClaimMessage(item.source_id))
|
|
1324
1660
|
continue;
|
|
1325
1661
|
let msg = null;
|
|
@@ -1347,11 +1683,11 @@ export class ParallAgentGateway {
|
|
|
1347
1683
|
continue;
|
|
1348
1684
|
}
|
|
1349
1685
|
const decision = await this.buildMessageDispatchDecision(item.chat_id, msg);
|
|
1350
|
-
if (decision.action ===
|
|
1686
|
+
if (decision.action === 'retry') {
|
|
1351
1687
|
this.dispatchedMessages.delete(item.source_id);
|
|
1352
1688
|
continue;
|
|
1353
1689
|
}
|
|
1354
|
-
if (decision.action ===
|
|
1690
|
+
if (decision.action === 'skip') {
|
|
1355
1691
|
this.dispatchedMessages.delete(item.source_id);
|
|
1356
1692
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
|
|
1357
1693
|
continue;
|
|
@@ -1370,13 +1706,20 @@ export class ParallAgentGateway {
|
|
|
1370
1706
|
// and the next page would be wasted API work the replacement pod redoes.
|
|
1371
1707
|
cursor = !this.shuttingDown && page.has_more ? page.next_cursor : undefined;
|
|
1372
1708
|
} while (cursor);
|
|
1373
|
-
if (processed > 0
|
|
1374
|
-
this.opts.log?.info(`dispatch catch-up: processed ${processed}
|
|
1709
|
+
if (processed > 0) {
|
|
1710
|
+
this.opts.log?.info(`dispatch catch-up: processed ${processed}`);
|
|
1375
1711
|
}
|
|
1376
1712
|
}
|
|
1377
1713
|
async handleHello(data) {
|
|
1378
1714
|
const { client, config, log } = this.opts;
|
|
1379
|
-
this.sessionId = data.session_id ??
|
|
1715
|
+
this.sessionId = data.session_id ?? '';
|
|
1716
|
+
if (this.forkStates.size > 0) {
|
|
1717
|
+
const targetIds = [...this.forkStates.keys()];
|
|
1718
|
+
log?.info(`aborting ${targetIds.length} active fork(s) on reconnect`);
|
|
1719
|
+
for (const targetId of targetIds) {
|
|
1720
|
+
this.abortFork(targetId, 'ws reconnect');
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1380
1723
|
const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
|
|
1381
1724
|
try {
|
|
1382
1725
|
const count = await fetchAllChats(client, config.org_id, this.chatInfoMap);
|
|
@@ -1397,7 +1740,7 @@ export class ParallAgentGateway {
|
|
|
1397
1740
|
log?.warn(`heartbeat drift ${drift}ms — event loop may be blocked`);
|
|
1398
1741
|
}
|
|
1399
1742
|
this.lastHeartbeatAt = now;
|
|
1400
|
-
if (this.opts.ws.state !==
|
|
1743
|
+
if (this.opts.ws.state !== 'connected')
|
|
1401
1744
|
return;
|
|
1402
1745
|
this.opts.ws.sendAgentHeartbeat(this.sessionId, {
|
|
1403
1746
|
hostname: os.hostname(),
|
|
@@ -1407,9 +1750,7 @@ export class ParallAgentGateway {
|
|
|
1407
1750
|
uptime: os.uptime(),
|
|
1408
1751
|
});
|
|
1409
1752
|
}, intervalSec * 1000);
|
|
1410
|
-
|
|
1411
|
-
this.hadSuccessfulHello = true;
|
|
1412
|
-
this.catchUpFromDispatch(isFirstHello).catch((err) => {
|
|
1753
|
+
this.catchUpFromDispatch().catch((err) => {
|
|
1413
1754
|
log?.warn(`dispatch catch-up failed: ${String(err)}`);
|
|
1414
1755
|
});
|
|
1415
1756
|
}
|
|
@@ -1458,10 +1799,6 @@ export class ParallAgentGateway {
|
|
|
1458
1799
|
}
|
|
1459
1800
|
if (this.heartbeatTimer)
|
|
1460
1801
|
clearInterval(this.heartbeatTimer);
|
|
1461
|
-
for (const [, dispatch] of this.activeDispatches) {
|
|
1462
|
-
clearInterval(dispatch.typingTimer);
|
|
1463
|
-
}
|
|
1464
|
-
this.activeDispatches.clear();
|
|
1465
1802
|
await this.opts.onBeforeDisconnect?.();
|
|
1466
1803
|
this.opts.ws.disconnect();
|
|
1467
1804
|
this.opts.log?.info(`disconnected`);
|