@parall/agent-core 1.30.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 +15 -13
- package/dist/gateway-base.d.ts.map +1 -1
- package/dist/gateway-base.js +662 -312
- package/dist/index.d.ts +15 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -11
- 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 +20 -0
- package/dist/provider-config.d.ts.map +1 -0
- package/dist/provider-config.js +41 -0
- 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 +998 -442
- package/src/index.ts +23 -12
- 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 +51 -0
- 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/src/gateway-base.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import * as os from
|
|
2
|
-
import * as fs from
|
|
3
|
-
import * as path from
|
|
4
|
-
import { MENTION_ALL_USER_ID, ParallClient, ParallWs } from
|
|
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, ParallClient, ParallWs } from '@parall/sdk';
|
|
5
5
|
import type {
|
|
6
6
|
AgentConfigUpdateData,
|
|
7
7
|
AgentNewSessionData,
|
|
@@ -18,8 +18,13 @@ import type {
|
|
|
18
18
|
Task,
|
|
19
19
|
TaskAssignedData,
|
|
20
20
|
TextContent,
|
|
21
|
-
} from
|
|
22
|
-
import {
|
|
21
|
+
} from '@parall/sdk';
|
|
22
|
+
import {
|
|
23
|
+
buildEventBody,
|
|
24
|
+
buildEventBodyForForkResult,
|
|
25
|
+
buildForkResultPrefix,
|
|
26
|
+
buildForkScopePrefix,
|
|
27
|
+
} from './event-format.js';
|
|
23
28
|
import type {
|
|
24
29
|
CleanupForkOpts,
|
|
25
30
|
DispatchAdapter,
|
|
@@ -27,28 +32,44 @@ import type {
|
|
|
27
32
|
ForkSessionHandle,
|
|
28
33
|
GatewayLogger,
|
|
29
34
|
RuntimeEvent,
|
|
30
|
-
} from
|
|
31
|
-
import { routeTrigger } from
|
|
35
|
+
} from './dispatch-adapter.js';
|
|
36
|
+
import { routeTrigger } from './routing.js';
|
|
32
37
|
import {
|
|
33
38
|
clearDispatchMessageId,
|
|
39
|
+
clearDispatchMetrics,
|
|
34
40
|
clearDispatchNoReply,
|
|
35
41
|
clearSessionMessageId,
|
|
42
|
+
getDispatchMetrics,
|
|
43
|
+
recordDeliverText,
|
|
44
|
+
recordMessageSend,
|
|
45
|
+
recordNoReply,
|
|
46
|
+
recordToolCall,
|
|
47
|
+
resetDispatchMetrics,
|
|
36
48
|
setDispatchMessageId,
|
|
37
49
|
setDispatchNoReply,
|
|
38
50
|
setSessionChatId,
|
|
39
51
|
setSessionMessageId,
|
|
40
|
-
} from
|
|
41
|
-
import
|
|
52
|
+
} from './session-state.js';
|
|
53
|
+
import {
|
|
54
|
+
isParallSendCommand,
|
|
55
|
+
isParallNoReplyCommand,
|
|
56
|
+
extractShellCommand,
|
|
57
|
+
} from './bridge-workspace.js';
|
|
58
|
+
import {
|
|
59
|
+
startDispatchSpan,
|
|
60
|
+
endDispatchSpan,
|
|
61
|
+
recordDispatchMetric,
|
|
62
|
+
recordMissingReply,
|
|
63
|
+
runWithSessionKey,
|
|
64
|
+
} from './telemetry.js';
|
|
65
|
+
import type { DispatchState, ParallEvent } from './types.js';
|
|
66
|
+
|
|
67
|
+
const LIVE_SESSION_STATUSES = new Set(['open', 'active', 'idle']);
|
|
42
68
|
|
|
43
69
|
type ChatInfo = {
|
|
44
|
-
type: Chat[
|
|
70
|
+
type: Chat['type'];
|
|
45
71
|
name: string | null;
|
|
46
|
-
agentRoutingMode: Chat[
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
type ActiveDispatch = {
|
|
50
|
-
count: number;
|
|
51
|
-
typingTimer: ReturnType<typeof setInterval>;
|
|
72
|
+
agentRoutingMode: Chat['agent_routing_mode'];
|
|
52
73
|
};
|
|
53
74
|
|
|
54
75
|
type ForkQueueItem = {
|
|
@@ -61,6 +82,8 @@ type ActiveForkState = {
|
|
|
61
82
|
targetId: string;
|
|
62
83
|
queue: ForkQueueItem[];
|
|
63
84
|
processedEvents: ParallEvent[];
|
|
85
|
+
deadlineTimer: ReturnType<typeof setTimeout> | null;
|
|
86
|
+
deadlineExceeded: boolean;
|
|
64
87
|
};
|
|
65
88
|
|
|
66
89
|
type DispatchableMessage = {
|
|
@@ -76,9 +99,9 @@ type DispatchableMessage = {
|
|
|
76
99
|
};
|
|
77
100
|
|
|
78
101
|
type MessageDispatchDecision =
|
|
79
|
-
| { action:
|
|
80
|
-
| { action:
|
|
81
|
-
| { action:
|
|
102
|
+
| { action: 'dispatch'; event: ParallEvent }
|
|
103
|
+
| { action: 'skip' }
|
|
104
|
+
| { action: 'retry' };
|
|
82
105
|
|
|
83
106
|
export type ParallGatewayOptions = {
|
|
84
107
|
accountId: string;
|
|
@@ -96,19 +119,32 @@ export type ParallGatewayOptions = {
|
|
|
96
119
|
runtimeRef?: Record<string, unknown>;
|
|
97
120
|
dispatchAdapter: DispatchAdapter;
|
|
98
121
|
log?: GatewayLogger;
|
|
122
|
+
/** @deprecated No-op. Cold-start time filter has been removed to prevent silent dispatch loss. */
|
|
99
123
|
coldStartWindowMs?: number;
|
|
100
124
|
// Maximum time to wait for in-flight dispatches to finish after SIGTERM /
|
|
101
125
|
// abort before forcing WS disconnect. Pod termination grace period should
|
|
102
126
|
// be at least this + a few seconds for the remaining cleanup work.
|
|
103
127
|
shutdownDeadlineMs?: number;
|
|
128
|
+
forkDeadlineMs?: number;
|
|
129
|
+
dispatchDeadlineMs?: number;
|
|
104
130
|
contextFilePathForSession?: (sessionKey: string) => string | undefined;
|
|
105
131
|
/** @deprecated Use contextFilePathForSession. Kept for runtimes that haven't migrated. */
|
|
106
132
|
stepIdFilePathForSession?: (sessionKey: string) => string | undefined;
|
|
107
133
|
onConfigUpdate?: (data: AgentConfigUpdateData) => Promise<void> | void;
|
|
108
|
-
onSessionReady?: (state: {
|
|
109
|
-
|
|
134
|
+
onSessionReady?: (state: {
|
|
135
|
+
activeSessionId?: string;
|
|
136
|
+
ws: ParallWs;
|
|
137
|
+
runtimeKey: string;
|
|
138
|
+
}) => Promise<void> | void;
|
|
139
|
+
onSessionBinding?: (state: {
|
|
140
|
+
sessionKey: string;
|
|
141
|
+
agentSessionId: string;
|
|
142
|
+
runtimeSessionId: string;
|
|
143
|
+
runtimeLaneKey: string;
|
|
144
|
+
}) => Promise<void> | void;
|
|
110
145
|
onBeforeDisconnect?: () => Promise<void> | void;
|
|
111
146
|
onNewSession?: (previousSessionId: string) => Promise<void> | void;
|
|
147
|
+
onSessionStale?: (sessionKey: string) => Promise<void> | void;
|
|
112
148
|
};
|
|
113
149
|
|
|
114
150
|
type AgentSessionBinding = {
|
|
@@ -134,17 +170,74 @@ export function parseShutdownDeadlineMs(raw: string | undefined): number | undef
|
|
|
134
170
|
return Math.floor(n);
|
|
135
171
|
}
|
|
136
172
|
|
|
173
|
+
export function parseForkDeadlineMs(raw: string | undefined): number | undefined {
|
|
174
|
+
if (!raw) return undefined;
|
|
175
|
+
const n = Number(raw);
|
|
176
|
+
if (!Number.isFinite(n) || n <= 0 || n > 2_147_483_647) return undefined;
|
|
177
|
+
return Math.floor(n);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function parseDispatchDeadlineMs(raw: string | undefined): number | undefined {
|
|
181
|
+
if (!raw) return undefined;
|
|
182
|
+
const n = Number(raw);
|
|
183
|
+
if (!Number.isFinite(n) || n < 0 || n > 2_147_483_647) return undefined;
|
|
184
|
+
return Math.floor(n);
|
|
185
|
+
}
|
|
186
|
+
|
|
137
187
|
function resolveStepTarget(event: ParallEvent): { target_type: string; target_id?: string } {
|
|
138
|
-
if (event.type ===
|
|
139
|
-
return { target_type:
|
|
188
|
+
if (event.type === 'task' || event.targetId.startsWith('tsk_')) {
|
|
189
|
+
return { target_type: 'task', target_id: event.targetId };
|
|
190
|
+
}
|
|
191
|
+
if (event.targetId.startsWith('cht_')) {
|
|
192
|
+
return { target_type: 'chat', target_id: event.targetId };
|
|
140
193
|
}
|
|
141
|
-
if (event.targetId.startsWith(
|
|
142
|
-
return { target_type:
|
|
194
|
+
if (event.type === 'schedule' || event.targetId.startsWith('sch_')) {
|
|
195
|
+
return { target_type: 'schedule', target_id: event.targetId };
|
|
143
196
|
}
|
|
144
|
-
if (event.type ===
|
|
145
|
-
|
|
197
|
+
if (event.type === 'wiki_comment') {
|
|
198
|
+
// target_id is the full wiki target_uri (scheme-stripped routing key). The
|
|
199
|
+
// server stores target_type freely and only publishes step WS events /
|
|
200
|
+
// projects for chat & task, so 'wiki' is an informational tag — no inline
|
|
201
|
+
// wiki step viewer exists yet.
|
|
202
|
+
return { target_type: 'wiki', target_id: event.targetId || undefined };
|
|
146
203
|
}
|
|
147
|
-
return { target_type:
|
|
204
|
+
return { target_type: '', target_id: event.targetId || undefined };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Minimal parse of a wiki / changeset comment `target_uri` into its routing key
|
|
209
|
+
* and a human label, without depending on `@parall/app`'s full `prll://` parser
|
|
210
|
+
* (agent-core only depends on `@parall/sdk`). Forms:
|
|
211
|
+
* prll://wik_abc/path/to/file.md[?rev=SHA#h=...] → wiki page / inline anchor
|
|
212
|
+
* prll://wcs_xyz?wiki=wik_abc → changeset
|
|
213
|
+
*
|
|
214
|
+
* `routingKey` is the full `target_uri` minus the `prll://` scheme — it is the
|
|
215
|
+
* gateway routing/serialization key, so distinct pages / inline anchors /
|
|
216
|
+
* changesets within the same wiki route as distinct conversations (using the
|
|
217
|
+
* bare `wik_`/`wcs_` entity id would collapse every comment in a wiki onto one
|
|
218
|
+
* lane). Stripping the scheme keeps `prll://${targetId}` correct everywhere it
|
|
219
|
+
* is reconstructed (event body, fork-scope prefix).
|
|
220
|
+
*/
|
|
221
|
+
function parseWikiCommentTarget(targetUri: string): {
|
|
222
|
+
routingKey: string;
|
|
223
|
+
label: string;
|
|
224
|
+
targetType: 'wiki' | 'changeset';
|
|
225
|
+
} {
|
|
226
|
+
const routingKey = targetUri.replace(/^prll:\/\//, '');
|
|
227
|
+
const entityId = routingKey.match(/^([^/?#]+)/)?.[1] ?? routingKey;
|
|
228
|
+
const targetType = entityId.startsWith('wcs_') ? 'changeset' : 'wiki';
|
|
229
|
+
let label = entityId;
|
|
230
|
+
if (targetType === 'wiki') {
|
|
231
|
+
const pathMatch = routingKey.slice(entityId.length).match(/^\/([^?#]+)/);
|
|
232
|
+
if (pathMatch) {
|
|
233
|
+
try {
|
|
234
|
+
label = decodeURIComponent(pathMatch[1]);
|
|
235
|
+
} catch {
|
|
236
|
+
label = pathMatch[1];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return { routingKey, label, targetType };
|
|
148
241
|
}
|
|
149
242
|
|
|
150
243
|
async function fetchAllChats(
|
|
@@ -171,8 +264,6 @@ async function fetchAllChats(
|
|
|
171
264
|
|
|
172
265
|
export class ParallAgentGateway {
|
|
173
266
|
private readonly chatInfoMap = new Map<string, ChatInfo>();
|
|
174
|
-
private readonly activeDispatches = new Map<string, ActiveDispatch>();
|
|
175
|
-
private readonly injectedTypingCounts = new Map<string, number>();
|
|
176
267
|
private readonly dispatchedTasks = new Set<string>();
|
|
177
268
|
private readonly dispatchedMessages = new Set<string>();
|
|
178
269
|
private readonly forkStates = new Map<string, ActiveForkState>();
|
|
@@ -183,11 +274,11 @@ export class ParallAgentGateway {
|
|
|
183
274
|
mainBuffer: [],
|
|
184
275
|
};
|
|
185
276
|
|
|
186
|
-
private sessionId =
|
|
277
|
+
private sessionId = '';
|
|
187
278
|
private activeSessionId: string | undefined;
|
|
188
279
|
private readonly sessionBindings = new Map<string, AgentSessionBinding>();
|
|
189
280
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
190
|
-
|
|
281
|
+
|
|
191
282
|
private lastHeartbeatAt = Date.now();
|
|
192
283
|
private draining = false;
|
|
193
284
|
|
|
@@ -201,15 +292,22 @@ export class ParallAgentGateway {
|
|
|
201
292
|
private pendingRestartNotification: string | null = null;
|
|
202
293
|
|
|
203
294
|
private readonly DISPATCHED_MESSAGES_CAP = 5000;
|
|
204
|
-
private readonly COLD_START_WINDOW_MS: number;
|
|
205
295
|
// SHUTDOWN_DEADLINE_MS is read by waitForDrain via the configured value
|
|
206
296
|
// below — kept as instance state so per-runtime configs can override it
|
|
207
297
|
// (see parseShutdownDeadlineMs and runtime entrypoints).
|
|
208
298
|
private readonly SHUTDOWN_DEADLINE_MS: number;
|
|
299
|
+
private readonly FORK_DEADLINE_MS: number;
|
|
300
|
+
private readonly DISPATCH_DEADLINE_MS: number;
|
|
209
301
|
|
|
210
302
|
constructor(private readonly opts: ParallGatewayOptions) {
|
|
211
|
-
this.COLD_START_WINDOW_MS = opts.coldStartWindowMs ?? 5 * 60_000;
|
|
212
303
|
this.SHUTDOWN_DEADLINE_MS = opts.shutdownDeadlineMs ?? 60_000;
|
|
304
|
+
this.FORK_DEADLINE_MS = opts.forkDeadlineMs ?? 2 * 60 * 60_000;
|
|
305
|
+
this.DISPATCH_DEADLINE_MS = opts.dispatchDeadlineMs ?? 20 * 60_000;
|
|
306
|
+
if (opts.coldStartWindowMs != null) {
|
|
307
|
+
opts.log?.warn?.(
|
|
308
|
+
'coldStartWindowMs is deprecated and ignored — cold-start time filter has been removed',
|
|
309
|
+
);
|
|
310
|
+
}
|
|
213
311
|
}
|
|
214
312
|
|
|
215
313
|
async run(abortSignal: AbortSignal): Promise<void> {
|
|
@@ -219,39 +317,39 @@ export class ParallAgentGateway {
|
|
|
219
317
|
log?.info(`connection state → ${state}`);
|
|
220
318
|
});
|
|
221
319
|
|
|
222
|
-
ws.on(
|
|
320
|
+
ws.on('hello', async (data: HelloData) => {
|
|
223
321
|
await this.handleHello(data);
|
|
224
322
|
});
|
|
225
323
|
|
|
226
|
-
ws.on(
|
|
324
|
+
ws.on('chat.update', (data: ChatUpdateData) => {
|
|
227
325
|
const changes = data.changes as Record<string, unknown> | undefined;
|
|
228
326
|
if (!changes) return;
|
|
229
327
|
const existing = this.chatInfoMap.get(data.chat_id);
|
|
230
328
|
if (existing) {
|
|
231
329
|
this.chatInfoMap.set(data.chat_id, {
|
|
232
330
|
...existing,
|
|
233
|
-
...(typeof changes.type ===
|
|
234
|
-
...(typeof changes.name ===
|
|
235
|
-
...(typeof changes.agent_routing_mode ===
|
|
236
|
-
? { agentRoutingMode: changes.agent_routing_mode as Chat[
|
|
331
|
+
...(typeof changes.type === 'string' ? { type: changes.type as Chat['type'] } : {}),
|
|
332
|
+
...(typeof changes.name === 'string' ? { name: changes.name } : {}),
|
|
333
|
+
...(typeof changes.agent_routing_mode === 'string'
|
|
334
|
+
? { agentRoutingMode: changes.agent_routing_mode as Chat['agent_routing_mode'] }
|
|
237
335
|
: {}),
|
|
238
336
|
});
|
|
239
|
-
} else if (typeof changes.type ===
|
|
337
|
+
} else if (typeof changes.type === 'string') {
|
|
240
338
|
this.chatInfoMap.set(data.chat_id, {
|
|
241
|
-
type: changes.type as Chat[
|
|
242
|
-
name: typeof changes.name ===
|
|
243
|
-
agentRoutingMode: (typeof changes.agent_routing_mode ===
|
|
339
|
+
type: changes.type as Chat['type'],
|
|
340
|
+
name: typeof changes.name === 'string' ? changes.name : null,
|
|
341
|
+
agentRoutingMode: (typeof changes.agent_routing_mode === 'string'
|
|
244
342
|
? changes.agent_routing_mode
|
|
245
|
-
:
|
|
343
|
+
: 'passive') as Chat['agent_routing_mode'],
|
|
246
344
|
});
|
|
247
345
|
}
|
|
248
346
|
});
|
|
249
347
|
|
|
250
|
-
ws.on(
|
|
348
|
+
ws.on('message.new', async (data: MessageNewData) => {
|
|
251
349
|
await this.handleMessage(data);
|
|
252
350
|
});
|
|
253
351
|
|
|
254
|
-
ws.on(
|
|
352
|
+
ws.on('agent_config.update', async (data: AgentConfigUpdateData) => {
|
|
255
353
|
this.opts.log?.info(`config update notification (version=${data.version})`);
|
|
256
354
|
try {
|
|
257
355
|
await this.opts.onConfigUpdate?.(data);
|
|
@@ -260,13 +358,12 @@ export class ParallAgentGateway {
|
|
|
260
358
|
}
|
|
261
359
|
});
|
|
262
360
|
|
|
263
|
-
ws.on(
|
|
264
|
-
const prevId = data.previous_session_id ??
|
|
361
|
+
ws.on('agent.new_session', async (data: AgentNewSessionData) => {
|
|
362
|
+
const prevId = data.previous_session_id ?? '';
|
|
265
363
|
this.opts.log?.info(`new session signal received (previous=${prevId})`);
|
|
266
364
|
this.sessionBindings.clear();
|
|
267
365
|
if (prevId) {
|
|
268
|
-
this.pendingRestartNotification =
|
|
269
|
-
`[Harness Notification] This is a fresh session. Your previous session (${prevId}) was ended by the user and you have been restarted.`;
|
|
366
|
+
this.pendingRestartNotification = `[Harness Notification] This is a fresh session. Your previous session (${prevId}) was ended by the user and you have been restarted.`;
|
|
270
367
|
}
|
|
271
368
|
try {
|
|
272
369
|
await this.opts.onNewSession?.(prevId);
|
|
@@ -275,47 +372,80 @@ export class ParallAgentGateway {
|
|
|
275
372
|
}
|
|
276
373
|
});
|
|
277
374
|
|
|
278
|
-
ws.on(
|
|
375
|
+
ws.on('recovery.overflow', () => {
|
|
279
376
|
this.opts.log?.warn(`recovery.overflow — triggering full catch-up`);
|
|
280
377
|
this.catchUpFromDispatch().catch((err) =>
|
|
281
|
-
this.opts.log?.warn(`overflow catch-up failed: ${String(err)}`)
|
|
378
|
+
this.opts.log?.warn(`overflow catch-up failed: ${String(err)}`),
|
|
379
|
+
);
|
|
282
380
|
});
|
|
283
381
|
|
|
284
|
-
ws.on(
|
|
382
|
+
ws.on('task.assigned', async (data: TaskAssignedData) => {
|
|
285
383
|
if (data.assignee_id !== this.opts.agentUserId) return;
|
|
286
|
-
if (data.status !==
|
|
384
|
+
if (data.status !== 'todo' && data.status !== 'in_progress') return;
|
|
287
385
|
try {
|
|
288
386
|
const dispatched = await this.handleTaskAssignment(data, data.id);
|
|
289
387
|
if (dispatched) {
|
|
290
|
-
this.opts.client
|
|
388
|
+
this.opts.client
|
|
389
|
+
.ackDispatch(this.opts.config.org_id, {
|
|
390
|
+
source_type: 'task_activity',
|
|
391
|
+
source_id: data.id,
|
|
392
|
+
})
|
|
393
|
+
.catch(() => {});
|
|
291
394
|
}
|
|
292
395
|
} catch (err) {
|
|
293
396
|
this.opts.log?.error(`task dispatch failed for ${data.id}: ${String(err)}`);
|
|
294
397
|
}
|
|
295
398
|
});
|
|
296
399
|
|
|
297
|
-
ws.on(
|
|
298
|
-
if (data.event_type ===
|
|
400
|
+
ws.on('dispatch.new', async (data: DispatchNewData) => {
|
|
401
|
+
if (data.event_type === 'task_comment') {
|
|
299
402
|
if (!data.source_id || !data.task_id) return;
|
|
300
403
|
try {
|
|
301
|
-
const dispatched = await this.handleTaskComment(
|
|
404
|
+
const dispatched = await this.handleTaskComment(
|
|
405
|
+
data.source_id,
|
|
406
|
+
data.task_id,
|
|
407
|
+
data.actor_id,
|
|
408
|
+
data.delivery_reason,
|
|
409
|
+
);
|
|
302
410
|
if (dispatched) {
|
|
303
411
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
304
412
|
}
|
|
305
413
|
} catch (err) {
|
|
306
|
-
this.opts.log?.error(
|
|
414
|
+
this.opts.log?.error(
|
|
415
|
+
`task comment dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
416
|
+
);
|
|
307
417
|
}
|
|
308
|
-
} else if (data.event_type ===
|
|
418
|
+
} else if (data.event_type === 'wiki_comment') {
|
|
419
|
+
if (!data.source_id) return;
|
|
420
|
+
try {
|
|
421
|
+
const dispatched = await this.handleWikiComment(
|
|
422
|
+
data.source_id,
|
|
423
|
+
data.actor_id,
|
|
424
|
+
data.delivery_reason,
|
|
425
|
+
);
|
|
426
|
+
if (dispatched) {
|
|
427
|
+
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
428
|
+
}
|
|
429
|
+
} catch (err) {
|
|
430
|
+
this.opts.log?.error(
|
|
431
|
+
`wiki comment dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
} else if (data.event_type === 'task_update') {
|
|
309
435
|
if (!data.task_id) return;
|
|
310
436
|
try {
|
|
311
|
-
const dispatched = await this.handleTaskDispatch(
|
|
437
|
+
const dispatched = await this.handleTaskDispatch(
|
|
438
|
+
data.task_id,
|
|
439
|
+
data.source_id ?? data.task_id,
|
|
440
|
+
{ allowCreator: true },
|
|
441
|
+
);
|
|
312
442
|
if (dispatched) {
|
|
313
443
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
314
444
|
}
|
|
315
445
|
} catch (err) {
|
|
316
446
|
this.opts.log?.error(`task update dispatch failed for ${data.task_id}: ${String(err)}`);
|
|
317
447
|
}
|
|
318
|
-
} else if (data.event_type ===
|
|
448
|
+
} else if (data.event_type === 'schedule.fire') {
|
|
319
449
|
if (!data.source_id) return;
|
|
320
450
|
try {
|
|
321
451
|
const dispatched = await this.fetchAndHandleScheduleFire(data.source_id, data.actor_id);
|
|
@@ -323,19 +453,27 @@ export class ParallAgentGateway {
|
|
|
323
453
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
324
454
|
}
|
|
325
455
|
} catch (err) {
|
|
326
|
-
this.opts.log?.error(
|
|
456
|
+
this.opts.log?.error(
|
|
457
|
+
`schedule fire dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
458
|
+
);
|
|
327
459
|
}
|
|
328
|
-
} else if (data.event_type ===
|
|
460
|
+
} else if (data.event_type === 'approval_decided') {
|
|
329
461
|
if (!data.source_id) return;
|
|
330
462
|
try {
|
|
331
|
-
const dispatched = await this.fetchAndHandleApprovalDecided(
|
|
463
|
+
const dispatched = await this.fetchAndHandleApprovalDecided(
|
|
464
|
+
data.source_id,
|
|
465
|
+
data.actor_id,
|
|
466
|
+
data.chat_id ?? null,
|
|
467
|
+
);
|
|
332
468
|
if (dispatched) {
|
|
333
469
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
334
470
|
}
|
|
335
471
|
} catch (err) {
|
|
336
|
-
this.opts.log?.error(
|
|
472
|
+
this.opts.log?.error(
|
|
473
|
+
`approval decided dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
474
|
+
);
|
|
337
475
|
}
|
|
338
|
-
} else if (data.event_type !==
|
|
476
|
+
} else if (data.event_type !== 'message' && data.event_type !== 'task_assign') {
|
|
339
477
|
// Truly unknown event_type — log so a newly-added dispatch type
|
|
340
478
|
// not yet wired here surfaces during runtime testing. "message"
|
|
341
479
|
// and "task_assign" are deliberately excluded: dispatch.new
|
|
@@ -343,15 +481,17 @@ export class ParallAgentGateway {
|
|
|
343
481
|
// (message.new, task.assigned) above and would otherwise spam
|
|
344
482
|
// info-level logs for every inbound chat message / task
|
|
345
483
|
// assignment on a busy agent.
|
|
346
|
-
this.opts.log?.info(
|
|
484
|
+
this.opts.log?.info(
|
|
485
|
+
`dispatch.new with unhandled event_type=${String(data.event_type)} (id=${data.id}) — no-op`,
|
|
486
|
+
);
|
|
347
487
|
}
|
|
348
488
|
});
|
|
349
489
|
|
|
350
|
-
this.opts.log?.info(`connecting to ${this.opts.connectionLabel ??
|
|
490
|
+
this.opts.log?.info(`connecting to ${this.opts.connectionLabel ?? 'Parall WS'}...`);
|
|
351
491
|
await ws.connect();
|
|
352
492
|
|
|
353
493
|
return new Promise<void>((resolve) => {
|
|
354
|
-
abortSignal.addEventListener(
|
|
494
|
+
abortSignal.addEventListener('abort', async () => {
|
|
355
495
|
await this.shutdown();
|
|
356
496
|
resolve();
|
|
357
497
|
});
|
|
@@ -371,65 +511,13 @@ export class ParallAgentGateway {
|
|
|
371
511
|
return true;
|
|
372
512
|
}
|
|
373
513
|
|
|
374
|
-
private
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
if (this.opts.ws.state === "connected") this.opts.ws.sendTyping(chatId, "start");
|
|
382
|
-
const typingRefresh = setInterval(() => {
|
|
383
|
-
if (this.opts.ws.state === "connected") this.opts.ws.sendTyping(chatId, "start");
|
|
384
|
-
}, 2000);
|
|
385
|
-
this.activeDispatches.set(chatId, { count: 1, typingTimer: typingRefresh });
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
private stopTyping(chatId: string) {
|
|
389
|
-
const dispatch = this.activeDispatches.get(chatId);
|
|
390
|
-
if (!dispatch) return;
|
|
391
|
-
dispatch.count--;
|
|
392
|
-
if (dispatch.count > 0) return;
|
|
393
|
-
|
|
394
|
-
clearInterval(dispatch.typingTimer);
|
|
395
|
-
this.activeDispatches.delete(chatId);
|
|
396
|
-
if (this.opts.ws.state === "connected") this.opts.ws.sendTyping(chatId, "stop");
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
private shouldShowTyping(event: ParallEvent): boolean {
|
|
400
|
-
return event.type === "message" && event.targetId.startsWith("cht_") && !event.noReply;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
private startInjectedTyping(event: ParallEvent) {
|
|
404
|
-
if (!this.shouldShowTyping(event)) return;
|
|
405
|
-
this.startTyping(event.targetId);
|
|
406
|
-
this.injectedTypingCounts.set(event.targetId, (this.injectedTypingCounts.get(event.targetId) ?? 0) + 1);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
private takeInjectedTypingCount(chatId: string): number {
|
|
410
|
-
const count = this.injectedTypingCounts.get(chatId) ?? 0;
|
|
411
|
-
this.injectedTypingCounts.delete(chatId);
|
|
412
|
-
return count;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
private async runDispatchWithTyping(
|
|
416
|
-
event: ParallEvent,
|
|
417
|
-
sessionKey: string,
|
|
418
|
-
bodyForAgent: string,
|
|
419
|
-
earlierEvents: ParallEvent[] = [],
|
|
420
|
-
captureText?: string[],
|
|
421
|
-
opts: { suppressStart?: boolean; injectedTypingCount?: number } = {},
|
|
422
|
-
): Promise<boolean> {
|
|
423
|
-
const showTyping = !opts.suppressStart && (this.shouldShowTyping(event) || earlierEvents.some(e => this.shouldShowTyping(e)));
|
|
424
|
-
if (showTyping) this.startTyping(event.targetId);
|
|
425
|
-
try {
|
|
426
|
-
return await this.runDispatch(event, sessionKey, bodyForAgent, earlierEvents, captureText);
|
|
427
|
-
} finally {
|
|
428
|
-
if (showTyping) this.stopTyping(event.targetId);
|
|
429
|
-
for (let i = 0; i < (opts.injectedTypingCount ?? 0); i++) {
|
|
430
|
-
this.stopTyping(event.targetId);
|
|
431
|
-
}
|
|
432
|
-
}
|
|
514
|
+
private async emitDispatchReceived(event: ParallEvent): Promise<void> {
|
|
515
|
+
const sourceType = event.ackSourceType ?? (event.type === 'task' ? 'task_activity' : 'message');
|
|
516
|
+
const sourceId = event.ackSourceId ?? event.messageId;
|
|
517
|
+
await this.opts.client.markDispatchReceived(this.opts.config.org_id, {
|
|
518
|
+
source_type: sourceType,
|
|
519
|
+
source_id: sourceId,
|
|
520
|
+
});
|
|
433
521
|
}
|
|
434
522
|
|
|
435
523
|
private buildDispatchContext(event: ParallEvent, sessionKey: string): DispatchContext {
|
|
@@ -443,7 +531,7 @@ export class ParallAgentGateway {
|
|
|
443
531
|
runtimeType: this.opts.runtimeType,
|
|
444
532
|
runtimeKey: this.opts.runtimeKey,
|
|
445
533
|
sessionId: binding?.agentSessionId,
|
|
446
|
-
chatId:
|
|
534
|
+
chatId: event.type === 'message' || event.type === 'approval' ? event.targetId : undefined,
|
|
447
535
|
triggerMessageId: event.messageId,
|
|
448
536
|
noReply: event.noReply ?? false,
|
|
449
537
|
contextFilePath: this.opts.contextFilePathForSession?.(sessionKey),
|
|
@@ -453,81 +541,127 @@ export class ParallAgentGateway {
|
|
|
453
541
|
};
|
|
454
542
|
}
|
|
455
543
|
|
|
544
|
+
private isSessionNotLiveError(err: unknown): boolean {
|
|
545
|
+
return (
|
|
546
|
+
err instanceof ApiError &&
|
|
547
|
+
err.status === 409 &&
|
|
548
|
+
(err.code === 'SESSION_NOT_LIVE' || err.code === 'INVALID_TRANSITION')
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
456
552
|
private async createInputStep(sessionId: string, event: ParallEvent) {
|
|
457
553
|
const target = resolveStepTarget(event);
|
|
458
554
|
try {
|
|
459
|
-
await this.opts.client.createAgentStep(
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
555
|
+
await this.opts.client.createAgentStep(
|
|
556
|
+
this.opts.config.org_id,
|
|
557
|
+
this.opts.agentUserId,
|
|
558
|
+
sessionId,
|
|
559
|
+
{
|
|
560
|
+
step_type: 'input',
|
|
561
|
+
target_type: target.target_type,
|
|
562
|
+
target_id: target.target_id,
|
|
563
|
+
content: {
|
|
564
|
+
trigger_type:
|
|
565
|
+
event.type === 'task'
|
|
566
|
+
? 'task_assign'
|
|
567
|
+
: event.type === 'task_comment'
|
|
568
|
+
? 'task_comment'
|
|
569
|
+
: event.type === 'wiki_comment'
|
|
570
|
+
? 'wiki_comment'
|
|
571
|
+
: event.type === 'schedule'
|
|
572
|
+
? 'schedule_fire'
|
|
573
|
+
: event.type === 'approval'
|
|
574
|
+
? 'approval_decided'
|
|
575
|
+
: 'mention',
|
|
576
|
+
trigger_ref:
|
|
577
|
+
event.type === 'task'
|
|
578
|
+
? { task_id: event.targetId }
|
|
579
|
+
: event.type === 'task_comment'
|
|
580
|
+
? { comment_id: event.messageId, task_id: event.targetId }
|
|
581
|
+
: event.type === 'wiki_comment'
|
|
582
|
+
? { comment_id: event.messageId, target_uri: event.replyTargetUri }
|
|
583
|
+
: event.type === 'schedule'
|
|
584
|
+
? { schedule_id: event.targetId, run_id: event.messageId }
|
|
585
|
+
: event.type === 'approval'
|
|
586
|
+
? { approval_id: event.messageId }
|
|
587
|
+
: { message_id: event.messageId },
|
|
588
|
+
sender_id: event.senderId,
|
|
589
|
+
sender_name: event.senderName,
|
|
590
|
+
summary: event.body.substring(0, 200),
|
|
591
|
+
...(event.sentAt ? { sent_at: event.sentAt } : {}),
|
|
592
|
+
},
|
|
480
593
|
},
|
|
481
|
-
|
|
594
|
+
);
|
|
482
595
|
} catch (err) {
|
|
596
|
+
if (this.isSessionNotLiveError(err)) throw err;
|
|
483
597
|
this.opts.log?.warn(`failed to create input step: ${String(err)}`);
|
|
484
598
|
}
|
|
485
599
|
}
|
|
486
600
|
|
|
487
|
-
private async createRuntimeStep(
|
|
488
|
-
|
|
601
|
+
private async createRuntimeStep(
|
|
602
|
+
sessionId: string,
|
|
603
|
+
event: ParallEvent,
|
|
604
|
+
runtimeEvent: RuntimeEvent,
|
|
605
|
+
stepIdFilePath?: string,
|
|
606
|
+
contextFilePath?: string,
|
|
607
|
+
) {
|
|
489
608
|
const target = resolveStepTarget(event);
|
|
490
609
|
try {
|
|
491
610
|
switch (runtimeEvent.type) {
|
|
492
|
-
case
|
|
493
|
-
await this.opts.client.createAgentStep(
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
611
|
+
case 'thinking':
|
|
612
|
+
await this.opts.client.createAgentStep(
|
|
613
|
+
this.opts.config.org_id,
|
|
614
|
+
this.opts.agentUserId,
|
|
615
|
+
sessionId,
|
|
616
|
+
{
|
|
617
|
+
step_type: 'thinking',
|
|
618
|
+
target_type: target.target_type,
|
|
619
|
+
target_id: target.target_id,
|
|
620
|
+
content: { text: runtimeEvent.text },
|
|
621
|
+
group_key: runtimeEvent.groupKey,
|
|
622
|
+
},
|
|
623
|
+
);
|
|
500
624
|
break;
|
|
501
625
|
|
|
502
|
-
case
|
|
503
|
-
await this.opts.client.createAgentStep(
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
626
|
+
case 'text':
|
|
627
|
+
await this.opts.client.createAgentStep(
|
|
628
|
+
this.opts.config.org_id,
|
|
629
|
+
this.opts.agentUserId,
|
|
630
|
+
sessionId,
|
|
631
|
+
{
|
|
632
|
+
step_type: 'text',
|
|
633
|
+
target_type: target.target_type,
|
|
634
|
+
target_id: target.target_id,
|
|
635
|
+
content: {
|
|
636
|
+
text: runtimeEvent.text,
|
|
637
|
+
suppressed: runtimeEvent.project !== true,
|
|
638
|
+
},
|
|
639
|
+
projection: runtimeEvent.project === true,
|
|
640
|
+
group_key: runtimeEvent.groupKey,
|
|
510
641
|
},
|
|
511
|
-
|
|
512
|
-
group_key: runtimeEvent.groupKey,
|
|
513
|
-
});
|
|
642
|
+
);
|
|
514
643
|
break;
|
|
515
644
|
|
|
516
|
-
case
|
|
517
|
-
const step = await this.opts.client.createAgentStep(
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
645
|
+
case 'tool_call': {
|
|
646
|
+
const step = await this.opts.client.createAgentStep(
|
|
647
|
+
this.opts.config.org_id,
|
|
648
|
+
this.opts.agentUserId,
|
|
649
|
+
sessionId,
|
|
650
|
+
{
|
|
651
|
+
step_type: 'tool_call',
|
|
652
|
+
target_type: target.target_type,
|
|
653
|
+
target_id: target.target_id,
|
|
654
|
+
content: {
|
|
655
|
+
call_id: runtimeEvent.callId,
|
|
656
|
+
tool_name: runtimeEvent.toolName,
|
|
657
|
+
tool_input: runtimeEvent.input,
|
|
658
|
+
status: 'running',
|
|
659
|
+
started_at: runtimeEvent.startedAt ?? new Date().toISOString(),
|
|
660
|
+
},
|
|
661
|
+
group_key: runtimeEvent.groupKey,
|
|
662
|
+
runtime_key: runtimeEvent.callId,
|
|
527
663
|
},
|
|
528
|
-
|
|
529
|
-
runtime_key: runtimeEvent.callId,
|
|
530
|
-
});
|
|
664
|
+
);
|
|
531
665
|
if (contextFilePath) {
|
|
532
666
|
this.updateContextFileStepId(contextFilePath, step.id);
|
|
533
667
|
} else if (stepIdFilePath) {
|
|
@@ -536,21 +670,26 @@ export class ParallAgentGateway {
|
|
|
536
670
|
break;
|
|
537
671
|
}
|
|
538
672
|
|
|
539
|
-
case
|
|
540
|
-
await this.opts.client.createAgentStep(
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
673
|
+
case 'tool_result':
|
|
674
|
+
await this.opts.client.createAgentStep(
|
|
675
|
+
this.opts.config.org_id,
|
|
676
|
+
this.opts.agentUserId,
|
|
677
|
+
sessionId,
|
|
678
|
+
{
|
|
679
|
+
step_type: 'tool_result',
|
|
680
|
+
target_type: target.target_type,
|
|
681
|
+
target_id: target.target_id,
|
|
682
|
+
content: {
|
|
683
|
+
call_id: runtimeEvent.callId,
|
|
684
|
+
tool_name: runtimeEvent.toolName,
|
|
685
|
+
status: runtimeEvent.error ? 'error' : 'success',
|
|
686
|
+
output: runtimeEvent.output,
|
|
687
|
+
duration_ms: runtimeEvent.durationMs ?? 0,
|
|
688
|
+
collapsible: true,
|
|
689
|
+
},
|
|
690
|
+
group_key: runtimeEvent.groupKey,
|
|
551
691
|
},
|
|
552
|
-
|
|
553
|
-
});
|
|
692
|
+
);
|
|
554
693
|
if (contextFilePath) {
|
|
555
694
|
this.updateContextFileStepId(contextFilePath, null);
|
|
556
695
|
} else if (stepIdFilePath) {
|
|
@@ -558,17 +697,23 @@ export class ParallAgentGateway {
|
|
|
558
697
|
}
|
|
559
698
|
break;
|
|
560
699
|
|
|
561
|
-
case
|
|
562
|
-
await this.opts.client.createAgentStep(
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
700
|
+
case 'error':
|
|
701
|
+
await this.opts.client.createAgentStep(
|
|
702
|
+
this.opts.config.org_id,
|
|
703
|
+
this.opts.agentUserId,
|
|
704
|
+
sessionId,
|
|
705
|
+
{
|
|
706
|
+
step_type: 'text',
|
|
707
|
+
target_type: target.target_type,
|
|
708
|
+
target_id: target.target_id,
|
|
709
|
+
content: { text: runtimeEvent.message, suppressed: false },
|
|
710
|
+
projection: false,
|
|
711
|
+
},
|
|
712
|
+
);
|
|
569
713
|
break;
|
|
570
714
|
}
|
|
571
715
|
} catch (err) {
|
|
716
|
+
if (this.isSessionNotLiveError(err)) throw err;
|
|
572
717
|
this.opts.log?.warn(`failed to create ${runtimeEvent.type} step: ${String(err)}`);
|
|
573
718
|
}
|
|
574
719
|
}
|
|
@@ -576,7 +721,7 @@ export class ParallAgentGateway {
|
|
|
576
721
|
private writeContextFile(filePath: string, ctx: Record<string, unknown>) {
|
|
577
722
|
try {
|
|
578
723
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
579
|
-
fs.writeFileSync(filePath, JSON.stringify(ctx),
|
|
724
|
+
fs.writeFileSync(filePath, JSON.stringify(ctx), 'utf8');
|
|
580
725
|
} catch (err) {
|
|
581
726
|
this.opts.log?.warn(`failed to write context file ${filePath}: ${String(err)}`);
|
|
582
727
|
}
|
|
@@ -584,10 +729,10 @@ export class ParallAgentGateway {
|
|
|
584
729
|
|
|
585
730
|
private updateContextFileStepId(filePath: string, stepId: string | null) {
|
|
586
731
|
try {
|
|
587
|
-
const raw = fs.readFileSync(filePath,
|
|
732
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
588
733
|
const ctx = JSON.parse(raw);
|
|
589
734
|
ctx.step_id = stepId;
|
|
590
|
-
fs.writeFileSync(filePath, JSON.stringify(ctx),
|
|
735
|
+
fs.writeFileSync(filePath, JSON.stringify(ctx), 'utf8');
|
|
591
736
|
} catch (err) {
|
|
592
737
|
this.opts.log?.warn(`failed to update context file step_id ${filePath}: ${String(err)}`);
|
|
593
738
|
}
|
|
@@ -595,10 +740,10 @@ export class ParallAgentGateway {
|
|
|
595
740
|
|
|
596
741
|
private updateContextFileSessionId(filePath: string, sessionId: string) {
|
|
597
742
|
try {
|
|
598
|
-
const raw = fs.readFileSync(filePath,
|
|
743
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
599
744
|
const ctx = JSON.parse(raw);
|
|
600
745
|
ctx.session_id = sessionId;
|
|
601
|
-
fs.writeFileSync(filePath, JSON.stringify(ctx),
|
|
746
|
+
fs.writeFileSync(filePath, JSON.stringify(ctx), 'utf8');
|
|
602
747
|
} catch (err) {
|
|
603
748
|
this.opts.log?.warn(`failed to update context file session_id ${filePath}: ${String(err)}`);
|
|
604
749
|
}
|
|
@@ -608,7 +753,7 @@ export class ParallAgentGateway {
|
|
|
608
753
|
private writeStepIdFile(filePath: string, stepId: string) {
|
|
609
754
|
try {
|
|
610
755
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
611
|
-
fs.writeFileSync(filePath, stepId,
|
|
756
|
+
fs.writeFileSync(filePath, stepId, 'utf8');
|
|
612
757
|
} catch (err) {
|
|
613
758
|
this.opts.log?.warn(`failed to write step id file ${filePath}: ${String(err)}`);
|
|
614
759
|
}
|
|
@@ -617,7 +762,7 @@ export class ParallAgentGateway {
|
|
|
617
762
|
/** @deprecated Use writeContextFile / updateContextFileStepId. */
|
|
618
763
|
private clearStepIdFile(filePath: string) {
|
|
619
764
|
try {
|
|
620
|
-
fs.writeFileSync(filePath,
|
|
765
|
+
fs.writeFileSync(filePath, '', 'utf8');
|
|
621
766
|
} catch {
|
|
622
767
|
// Best-effort cleanup.
|
|
623
768
|
}
|
|
@@ -631,7 +776,7 @@ export class ParallAgentGateway {
|
|
|
631
776
|
|
|
632
777
|
private async bindRuntimeSession(
|
|
633
778
|
sessionKey: string,
|
|
634
|
-
runtimeEvent: Extract<RuntimeEvent, { type:
|
|
779
|
+
runtimeEvent: Extract<RuntimeEvent, { type: 'runtime_session' }>,
|
|
635
780
|
contextFilePath?: string,
|
|
636
781
|
): Promise<AgentSessionBinding> {
|
|
637
782
|
const runtimeLaneKey = runtimeEvent.runtimeLaneKey || sessionKey;
|
|
@@ -644,21 +789,42 @@ export class ParallAgentGateway {
|
|
|
644
789
|
return existing;
|
|
645
790
|
}
|
|
646
791
|
|
|
647
|
-
const parentSessionId =
|
|
648
|
-
|
|
649
|
-
|
|
792
|
+
const parentSessionId =
|
|
793
|
+
sessionKey === this.opts.runtimeKey
|
|
794
|
+
? undefined
|
|
795
|
+
: this.sessionBindings.get(this.opts.runtimeKey)?.agentSessionId;
|
|
650
796
|
const runtimeRef = {
|
|
651
797
|
...(this.opts.runtimeRef ?? {}),
|
|
652
798
|
...(runtimeEvent.runtimeRef ?? {}),
|
|
653
799
|
};
|
|
654
|
-
const session: AgentSessionDB = await this.opts.client.createAgentSession(
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
800
|
+
const session: AgentSessionDB = await this.opts.client.createAgentSession(
|
|
801
|
+
this.opts.config.org_id,
|
|
802
|
+
this.opts.agentUserId,
|
|
803
|
+
{
|
|
804
|
+
runtime_type: this.opts.runtimeType,
|
|
805
|
+
runtime_key: runtimeLaneKey,
|
|
806
|
+
runtime_lane_key: runtimeLaneKey,
|
|
807
|
+
runtime_session_id: runtimeEvent.runtimeSessionId,
|
|
808
|
+
parent_session_id: parentSessionId,
|
|
809
|
+
runtime_ref: Object.keys(runtimeRef).length > 0 ? runtimeRef : undefined,
|
|
810
|
+
},
|
|
811
|
+
);
|
|
812
|
+
if (!LIVE_SESSION_STATUSES.has(session.status)) {
|
|
813
|
+
this.opts.log?.warn?.(
|
|
814
|
+
`createAgentSession returned terminal session ${session.id} (${session.status}), resetting runtime for ${sessionKey}`,
|
|
815
|
+
);
|
|
816
|
+
this.sessionBindings.delete(sessionKey);
|
|
817
|
+
try {
|
|
818
|
+
await this.opts.onSessionStale?.(sessionKey);
|
|
819
|
+
} catch (e) {
|
|
820
|
+
this.opts.log?.warn?.(`onSessionStale failed: ${e}`);
|
|
821
|
+
}
|
|
822
|
+
this.opts.log?.info?.(
|
|
823
|
+
`stale session self-heal complete for ${sessionKey} — next dispatch will create a fresh session`,
|
|
824
|
+
);
|
|
825
|
+
throw new Error(`Agent session ${session.id} is terminal (${session.status})`);
|
|
826
|
+
}
|
|
827
|
+
|
|
662
828
|
const binding: AgentSessionBinding = {
|
|
663
829
|
sessionKey,
|
|
664
830
|
agentSessionId: session.id,
|
|
@@ -699,125 +865,274 @@ export class ParallAgentGateway {
|
|
|
699
865
|
}
|
|
700
866
|
|
|
701
867
|
if (this.pendingRestartNotification) {
|
|
702
|
-
bodyForAgent = this.pendingRestartNotification +
|
|
868
|
+
bodyForAgent = this.pendingRestartNotification + '\n\n---\n\n' + bodyForAgent;
|
|
703
869
|
this.pendingRestartNotification = null;
|
|
704
870
|
}
|
|
705
871
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
setDispatchNoReply(sessionKey, event.noReply ?? false);
|
|
872
|
+
resetDispatchMetrics(sessionKey);
|
|
873
|
+
return runWithSessionKey(sessionKey, async () => {
|
|
874
|
+
let dispatchSpan: ReturnType<typeof startDispatchSpan> = null;
|
|
710
875
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
876
|
+
setSessionChatId(sessionKey, event.targetId);
|
|
877
|
+
setSessionMessageId(sessionKey, event.messageId);
|
|
878
|
+
setDispatchMessageId(sessionKey, event.messageId);
|
|
879
|
+
setDispatchNoReply(sessionKey, event.noReply ?? false);
|
|
714
880
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
chat_id: dispatchContext.chatId ?? null,
|
|
719
|
-
trigger_message_id: dispatchContext.triggerMessageId ?? null,
|
|
720
|
-
no_reply: dispatchContext.noReply,
|
|
721
|
-
step_id: null,
|
|
722
|
-
});
|
|
723
|
-
}
|
|
881
|
+
const dispatchContext = this.buildDispatchContext(event, sessionKey);
|
|
882
|
+
const contextFilePath = dispatchContext.contextFilePath;
|
|
883
|
+
const stepIdFilePath = dispatchContext.stepIdFilePath;
|
|
724
884
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
885
|
+
if (contextFilePath) {
|
|
886
|
+
this.writeContextFile(contextFilePath, {
|
|
887
|
+
session_id: dispatchContext.sessionId ?? null,
|
|
888
|
+
chat_id: dispatchContext.chatId ?? null,
|
|
889
|
+
trigger_message_id: dispatchContext.triggerMessageId ?? null,
|
|
890
|
+
no_reply: dispatchContext.noReply,
|
|
891
|
+
step_id: null,
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// sync: no await between the shuttingDown check above and this increment
|
|
896
|
+
// — JS event loop is single-threaded, so shutdown() cannot interleave
|
|
897
|
+
// here and miss our in-flight count.
|
|
898
|
+
this.inFlightDispatches++;
|
|
899
|
+
|
|
900
|
+
const deadlineTimer =
|
|
901
|
+
this.DISPATCH_DEADLINE_MS > 0
|
|
902
|
+
? setTimeout(() => {
|
|
903
|
+
this.opts.log?.warn(
|
|
904
|
+
`dispatch deadline exceeded (${this.DISPATCH_DEADLINE_MS}ms) for ${event.messageId} on ${sessionKey}; aborting`,
|
|
905
|
+
);
|
|
906
|
+
try {
|
|
907
|
+
this.opts.dispatchAdapter.abortDispatch?.(sessionKey);
|
|
908
|
+
} catch (err) {
|
|
909
|
+
this.opts.log?.warn(`abortDispatch threw for ${sessionKey}: ${String(err)}`);
|
|
910
|
+
}
|
|
911
|
+
}, this.DISPATCH_DEADLINE_MS)
|
|
912
|
+
: null;
|
|
913
|
+
|
|
914
|
+
let binding = this.sessionBindings.get(sessionKey);
|
|
915
|
+
let inputStepsCreated = false;
|
|
916
|
+
let triggerMessageSet = false;
|
|
917
|
+
let dispatchError: unknown;
|
|
918
|
+
const pendingSendCallIds = new Set<string>();
|
|
919
|
+
try {
|
|
920
|
+
dispatchSpan = startDispatchSpan(event, this.opts.runtimeType, sessionKey);
|
|
921
|
+
for await (const runtimeEvent of this.opts.dispatchAdapter.dispatch({
|
|
922
|
+
event,
|
|
923
|
+
earlierEvents,
|
|
924
|
+
bodyForAgent,
|
|
925
|
+
sessionKey,
|
|
926
|
+
context: dispatchContext,
|
|
927
|
+
})) {
|
|
928
|
+
if (runtimeEvent.type === 'runtime_session') {
|
|
929
|
+
binding = await this.bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath);
|
|
930
|
+
if (!inputStepsCreated) {
|
|
931
|
+
// Persist input steps for "earlier events" (batched events that arrived
|
|
932
|
+
// while a dispatch was in flight) inside the in-flight window so a
|
|
933
|
+
// shutdown short-circuit BEFORE this point cannot leave orphan input
|
|
934
|
+
// steps that the replacement pod would duplicate on replay.
|
|
935
|
+
if (earlierEvents.length > 0) {
|
|
936
|
+
await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
|
|
937
|
+
}
|
|
938
|
+
await this.createInputStep(binding.agentSessionId, event);
|
|
939
|
+
inputStepsCreated = true;
|
|
940
|
+
}
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (!binding) {
|
|
945
|
+
const detail = runtimeEvent.type === 'error' ? `: ${runtimeEvent.message}` : '';
|
|
946
|
+
throw new Error(`runtime emitted ${runtimeEvent.type} before runtime_session${detail}`);
|
|
947
|
+
}
|
|
948
|
+
if (!triggerMessageSet) {
|
|
949
|
+
triggerMessageSet = true;
|
|
950
|
+
this.opts.client
|
|
951
|
+
.updateAgentSession(
|
|
952
|
+
this.opts.config.org_id,
|
|
953
|
+
this.opts.agentUserId,
|
|
954
|
+
binding.agentSessionId,
|
|
955
|
+
{ status: 'active', trigger_message_id: event.messageId },
|
|
956
|
+
)
|
|
957
|
+
.catch((err) => this.opts.log?.warn?.(`failed to set session active: ${err}`));
|
|
958
|
+
}
|
|
742
959
|
if (!inputStepsCreated) {
|
|
743
|
-
// Persist input steps for "earlier events" (batched events that arrived
|
|
744
|
-
// while a dispatch was in flight) inside the in-flight window so a
|
|
745
|
-
// shutdown short-circuit BEFORE this point cannot leave orphan input
|
|
746
|
-
// steps that the replacement pod would duplicate on replay.
|
|
747
960
|
if (earlierEvents.length > 0) {
|
|
748
961
|
await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
|
|
749
962
|
}
|
|
750
963
|
await this.createInputStep(binding.agentSessionId, event);
|
|
751
964
|
inputStepsCreated = true;
|
|
752
965
|
}
|
|
753
|
-
|
|
966
|
+
if (captureText && runtimeEvent.type === 'text' && runtimeEvent.text) {
|
|
967
|
+
captureText.push(runtimeEvent.text);
|
|
968
|
+
}
|
|
969
|
+
if (runtimeEvent.type === 'text' && runtimeEvent.text.length > 0) {
|
|
970
|
+
recordDeliverText(sessionKey, runtimeEvent.text.length);
|
|
971
|
+
} else if (runtimeEvent.type === 'tool_call') {
|
|
972
|
+
recordToolCall(sessionKey);
|
|
973
|
+
const cmd = extractShellCommand(runtimeEvent.input);
|
|
974
|
+
if (isParallSendCommand(cmd)) {
|
|
975
|
+
pendingSendCallIds.add(runtimeEvent.callId);
|
|
976
|
+
} else if (isParallNoReplyCommand(cmd)) {
|
|
977
|
+
recordNoReply(sessionKey);
|
|
978
|
+
}
|
|
979
|
+
} else if (
|
|
980
|
+
runtimeEvent.type === 'tool_result' &&
|
|
981
|
+
pendingSendCallIds.delete(runtimeEvent.callId)
|
|
982
|
+
) {
|
|
983
|
+
recordMessageSend(sessionKey, !runtimeEvent.error);
|
|
984
|
+
}
|
|
985
|
+
await this.createRuntimeStep(
|
|
986
|
+
binding.agentSessionId,
|
|
987
|
+
event,
|
|
988
|
+
runtimeEvent,
|
|
989
|
+
stepIdFilePath,
|
|
990
|
+
contextFilePath,
|
|
991
|
+
);
|
|
754
992
|
}
|
|
755
|
-
|
|
756
993
|
if (!binding) {
|
|
757
|
-
|
|
758
|
-
throw new Error(`runtime emitted ${runtimeEvent.type} before runtime_session${detail}`);
|
|
994
|
+
binding = this.sessionBindings.get(sessionKey);
|
|
759
995
|
}
|
|
760
|
-
if (!
|
|
761
|
-
|
|
762
|
-
this.opts.client.updateAgentSession(
|
|
763
|
-
this.opts.config.org_id, this.opts.agentUserId, binding.agentSessionId,
|
|
764
|
-
{ status: "active", trigger_message_id: event.messageId },
|
|
765
|
-
).catch((err) => this.opts.log?.warn?.(`failed to set session active: ${err}`));
|
|
996
|
+
if (!binding) {
|
|
997
|
+
throw new Error('runtime completed without runtime_session');
|
|
766
998
|
}
|
|
767
999
|
if (!inputStepsCreated) {
|
|
768
1000
|
if (earlierEvents.length > 0) {
|
|
769
1001
|
await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
|
|
770
1002
|
}
|
|
771
1003
|
await this.createInputStep(binding.agentSessionId, event);
|
|
772
|
-
inputStepsCreated = true;
|
|
773
1004
|
}
|
|
774
|
-
|
|
775
|
-
|
|
1005
|
+
} catch (err) {
|
|
1006
|
+
dispatchError = err;
|
|
1007
|
+
let staleDetected = this.isSessionNotLiveError(err);
|
|
1008
|
+
if (!staleDetected && binding) {
|
|
1009
|
+
try {
|
|
1010
|
+
await this.createRuntimeStep(
|
|
1011
|
+
binding.agentSessionId,
|
|
1012
|
+
event,
|
|
1013
|
+
{
|
|
1014
|
+
type: 'error',
|
|
1015
|
+
message: `Dispatch failed: ${String(err)}`,
|
|
1016
|
+
},
|
|
1017
|
+
stepIdFilePath,
|
|
1018
|
+
contextFilePath,
|
|
1019
|
+
);
|
|
1020
|
+
} catch (stepErr) {
|
|
1021
|
+
if (this.isSessionNotLiveError(stepErr)) staleDetected = true;
|
|
1022
|
+
}
|
|
776
1023
|
}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1024
|
+
if (staleDetected && binding) {
|
|
1025
|
+
this.opts.log?.warn?.(
|
|
1026
|
+
`session ${binding.agentSessionId} is stale (mid-dispatch), triggering recovery for ${sessionKey}`,
|
|
1027
|
+
);
|
|
1028
|
+
this.sessionBindings.delete(sessionKey);
|
|
1029
|
+
if (sessionKey === this.opts.runtimeKey) {
|
|
1030
|
+
this.activeSessionId = undefined;
|
|
1031
|
+
}
|
|
1032
|
+
try {
|
|
1033
|
+
await this.opts.onSessionStale?.(sessionKey);
|
|
1034
|
+
} catch (e) {
|
|
1035
|
+
this.opts.log?.warn?.(`onSessionStale failed: ${e}`);
|
|
1036
|
+
}
|
|
1037
|
+
this.opts.log?.info?.(
|
|
1038
|
+
`stale session self-heal complete for ${sessionKey} — next dispatch will create a fresh session`,
|
|
1039
|
+
);
|
|
1040
|
+
binding = undefined;
|
|
1041
|
+
}
|
|
1042
|
+
throw err;
|
|
1043
|
+
} finally {
|
|
1044
|
+
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
1045
|
+
const metricsSnapshot = getDispatchMetrics(sessionKey);
|
|
1046
|
+
const durationMs = metricsSnapshot ? Date.now() - metricsSnapshot.started_at : 0;
|
|
1047
|
+
|
|
1048
|
+
endDispatchSpan(dispatchSpan, metricsSnapshot, dispatchError);
|
|
1049
|
+
recordDispatchMetric(event, this.opts.runtimeType, durationMs);
|
|
1050
|
+
|
|
1051
|
+
if (
|
|
1052
|
+
metricsSnapshot &&
|
|
1053
|
+
!dispatchError &&
|
|
1054
|
+
event.type === 'message' &&
|
|
1055
|
+
event.targetId?.startsWith('cht_') &&
|
|
1056
|
+
!event.noReply &&
|
|
1057
|
+
metricsSnapshot.deliver_text_chunks > 0 &&
|
|
1058
|
+
metricsSnapshot.message_send_successes === 0 &&
|
|
1059
|
+
!metricsSnapshot.no_reply_called
|
|
1060
|
+
) {
|
|
1061
|
+
recordMissingReply(this.opts.runtimeType);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
clearDispatchMetrics(sessionKey);
|
|
1065
|
+
if (triggerMessageSet && binding) {
|
|
1066
|
+
this.opts.client
|
|
1067
|
+
.updateAgentSession(
|
|
1068
|
+
this.opts.config.org_id,
|
|
1069
|
+
this.opts.agentUserId,
|
|
1070
|
+
binding.agentSessionId,
|
|
1071
|
+
{ status: 'idle' },
|
|
1072
|
+
)
|
|
1073
|
+
.catch((err) => this.opts.log?.warn?.(`failed to set session idle: ${err}`));
|
|
1074
|
+
}
|
|
1075
|
+
clearSessionMessageId(sessionKey);
|
|
1076
|
+
clearDispatchMessageId(sessionKey);
|
|
1077
|
+
clearDispatchNoReply(sessionKey);
|
|
1078
|
+
if (contextFilePath) {
|
|
1079
|
+
this.updateContextFileStepId(contextFilePath, null);
|
|
1080
|
+
} else if (stepIdFilePath) {
|
|
1081
|
+
this.clearStepIdFile(stepIdFilePath);
|
|
1082
|
+
}
|
|
1083
|
+
this.inFlightDispatches--;
|
|
1084
|
+
if (this.inFlightDispatches === 0 && this.drainResolvers.length > 0) {
|
|
1085
|
+
const resolvers = this.drainResolvers.splice(0);
|
|
1086
|
+
for (const resolve of resolvers) resolve();
|
|
788
1087
|
}
|
|
789
|
-
await this.createInputStep(binding.agentSessionId, event);
|
|
790
|
-
}
|
|
791
|
-
} catch (err) {
|
|
792
|
-
if (binding) {
|
|
793
|
-
await this.createRuntimeStep(binding.agentSessionId, event, {
|
|
794
|
-
type: "error",
|
|
795
|
-
message: `Dispatch failed: ${String(err)}`,
|
|
796
|
-
}, stepIdFilePath, contextFilePath);
|
|
797
|
-
}
|
|
798
|
-
throw err;
|
|
799
|
-
} finally {
|
|
800
|
-
if (triggerMessageSet && binding) {
|
|
801
|
-
this.opts.client.updateAgentSession(
|
|
802
|
-
this.opts.config.org_id, this.opts.agentUserId, binding.agentSessionId,
|
|
803
|
-
{ status: "idle" },
|
|
804
|
-
).catch((err) => this.opts.log?.warn?.(`failed to set session idle: ${err}`));
|
|
805
|
-
}
|
|
806
|
-
clearSessionMessageId(sessionKey);
|
|
807
|
-
clearDispatchMessageId(sessionKey);
|
|
808
|
-
clearDispatchNoReply(sessionKey);
|
|
809
|
-
if (contextFilePath) {
|
|
810
|
-
this.updateContextFileStepId(contextFilePath, null);
|
|
811
|
-
} else if (stepIdFilePath) {
|
|
812
|
-
this.clearStepIdFile(stepIdFilePath);
|
|
813
|
-
}
|
|
814
|
-
this.inFlightDispatches--;
|
|
815
|
-
if (this.inFlightDispatches === 0 && this.drainResolvers.length > 0) {
|
|
816
|
-
const resolvers = this.drainResolvers.splice(0);
|
|
817
|
-
for (const resolve of resolvers) resolve();
|
|
818
1088
|
}
|
|
1089
|
+
return true;
|
|
1090
|
+
}); // runWithSessionKey
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
private abortFork(targetId: string, reason: string) {
|
|
1094
|
+
const forkState = this.forkStates.get(targetId);
|
|
1095
|
+
if (!forkState) return;
|
|
1096
|
+
|
|
1097
|
+
this.opts.log?.warn(`aborting fork for ${targetId}: ${reason}`);
|
|
1098
|
+
forkState.deadlineExceeded = true;
|
|
1099
|
+
|
|
1100
|
+
if (forkState.deadlineTimer) {
|
|
1101
|
+
clearTimeout(forkState.deadlineTimer);
|
|
1102
|
+
forkState.deadlineTimer = null;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
this.dispatchState.activeForks.delete(targetId);
|
|
1106
|
+
this.forkStates.delete(targetId);
|
|
1107
|
+
|
|
1108
|
+
for (const item of forkState.queue.splice(0)) {
|
|
1109
|
+
item.resolve(false);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
if (this.opts.dispatchAdapter.cleanupFork) {
|
|
1113
|
+
const forkBinding = this.sessionBindings.get(forkState.fork.sessionKey);
|
|
1114
|
+
const cleanupOpts: CleanupForkOpts = {
|
|
1115
|
+
fork: forkState.fork,
|
|
1116
|
+
context: {
|
|
1117
|
+
accountId: this.opts.accountId,
|
|
1118
|
+
apiUrl: this.opts.config.parall_url,
|
|
1119
|
+
apiKey: this.opts.config.api_key,
|
|
1120
|
+
orgId: this.opts.config.org_id,
|
|
1121
|
+
agentUserId: this.opts.agentUserId,
|
|
1122
|
+
runtimeType: this.opts.runtimeType,
|
|
1123
|
+
runtimeKey: this.opts.runtimeKey,
|
|
1124
|
+
sessionId: forkBinding?.agentSessionId,
|
|
1125
|
+
noReply: false,
|
|
1126
|
+
client: this.opts.client,
|
|
1127
|
+
log: this.opts.log,
|
|
1128
|
+
},
|
|
1129
|
+
};
|
|
1130
|
+
void Promise.resolve()
|
|
1131
|
+
.then(() => this.opts.dispatchAdapter.cleanupFork?.(cleanupOpts))
|
|
1132
|
+
.catch((err) => {
|
|
1133
|
+
this.opts.log?.warn(`abortFork cleanupFork error for ${targetId}: ${String(err)}`);
|
|
1134
|
+
});
|
|
819
1135
|
}
|
|
820
|
-
return true;
|
|
821
1136
|
}
|
|
822
1137
|
|
|
823
1138
|
private async runForkDrainLoop(fork: ActiveForkState) {
|
|
@@ -830,7 +1145,7 @@ export class ParallAgentGateway {
|
|
|
830
1145
|
// shuttingDown gate and inFlightDispatches counter, so a shutdown
|
|
831
1146
|
// landing mid-batch cannot leave orphan steps that replay would
|
|
832
1147
|
// duplicate.
|
|
833
|
-
if (this.shuttingDown) {
|
|
1148
|
+
if (this.shuttingDown || fork.deadlineExceeded) {
|
|
834
1149
|
for (const item of fork.queue.splice(0)) item.resolve(false);
|
|
835
1150
|
break;
|
|
836
1151
|
}
|
|
@@ -840,7 +1155,13 @@ export class ParallAgentGateway {
|
|
|
840
1155
|
const earlier = events.slice(0, -1);
|
|
841
1156
|
try {
|
|
842
1157
|
const batchText: string[] = [];
|
|
843
|
-
const dispatched = await this.
|
|
1158
|
+
const dispatched = await this.runDispatch(
|
|
1159
|
+
last,
|
|
1160
|
+
fork.fork.sessionKey,
|
|
1161
|
+
buildForkScopePrefix(last) + buildEventBody(last),
|
|
1162
|
+
earlier,
|
|
1163
|
+
batchText,
|
|
1164
|
+
);
|
|
844
1165
|
if (!dispatched) {
|
|
845
1166
|
// Shutdown short-circuit — resolve un-acked so the server requeues
|
|
846
1167
|
// for the replacement pod and stop draining further items.
|
|
@@ -850,6 +1171,10 @@ export class ParallAgentGateway {
|
|
|
850
1171
|
break;
|
|
851
1172
|
}
|
|
852
1173
|
if (batchText.length > 0) lastCapturedText = batchText;
|
|
1174
|
+
if (fork.deadlineExceeded) {
|
|
1175
|
+
for (const item of items) item.resolve(false);
|
|
1176
|
+
break;
|
|
1177
|
+
}
|
|
853
1178
|
fork.processedEvents.push(...events);
|
|
854
1179
|
for (const item of items) {
|
|
855
1180
|
item.resolve(true);
|
|
@@ -867,9 +1192,13 @@ export class ParallAgentGateway {
|
|
|
867
1192
|
remaining.resolve(false);
|
|
868
1193
|
}
|
|
869
1194
|
} finally {
|
|
1195
|
+
if (fork.deadlineTimer) {
|
|
1196
|
+
clearTimeout(fork.deadlineTimer);
|
|
1197
|
+
fork.deadlineTimer = null;
|
|
1198
|
+
}
|
|
870
1199
|
if (fork.processedEvents.length > 0) {
|
|
871
1200
|
const first = fork.processedEvents[0];
|
|
872
|
-
const agentSummary = lastCapturedText.join(
|
|
1201
|
+
const agentSummary = lastCapturedText.join('').trim() || undefined;
|
|
873
1202
|
let historyPath: string | undefined;
|
|
874
1203
|
try {
|
|
875
1204
|
historyPath = this.opts.dispatchAdapter.getSessionHistoryPath?.(fork.fork.sessionKey);
|
|
@@ -881,9 +1210,10 @@ export class ParallAgentGateway {
|
|
|
881
1210
|
sourceEvent: {
|
|
882
1211
|
type: first.type,
|
|
883
1212
|
targetId: fork.targetId,
|
|
884
|
-
summary:
|
|
885
|
-
|
|
886
|
-
|
|
1213
|
+
summary:
|
|
1214
|
+
fork.processedEvents.length === 1
|
|
1215
|
+
? `${first.type} from ${first.senderName} in ${first.targetName ?? fork.targetId}`
|
|
1216
|
+
: `${fork.processedEvents.length} events in ${first.targetName ?? fork.targetId}`,
|
|
887
1217
|
},
|
|
888
1218
|
eventBodies: fork.processedEvents.map((e) => buildEventBodyForForkResult(e)),
|
|
889
1219
|
actions: [],
|
|
@@ -891,8 +1221,12 @@ export class ParallAgentGateway {
|
|
|
891
1221
|
historyPath,
|
|
892
1222
|
});
|
|
893
1223
|
}
|
|
894
|
-
this.forkStates.
|
|
895
|
-
|
|
1224
|
+
if (this.forkStates.get(fork.targetId) === fork) {
|
|
1225
|
+
this.forkStates.delete(fork.targetId);
|
|
1226
|
+
}
|
|
1227
|
+
if (this.dispatchState.activeForks.get(fork.targetId) === fork.fork.sessionKey) {
|
|
1228
|
+
this.dispatchState.activeForks.delete(fork.targetId);
|
|
1229
|
+
}
|
|
896
1230
|
const forkBinding = this.sessionBindings.get(fork.fork.sessionKey);
|
|
897
1231
|
if (this.opts.dispatchAdapter.cleanupFork) {
|
|
898
1232
|
const cleanupOpts: CleanupForkOpts = {
|
|
@@ -911,13 +1245,21 @@ export class ParallAgentGateway {
|
|
|
911
1245
|
log: this.opts.log,
|
|
912
1246
|
},
|
|
913
1247
|
};
|
|
914
|
-
|
|
1248
|
+
try {
|
|
1249
|
+
await this.opts.dispatchAdapter.cleanupFork(cleanupOpts);
|
|
1250
|
+
} catch (err) {
|
|
1251
|
+
this.opts.log?.warn(`fork cleanupFork error for ${fork.targetId}: ${String(err)}`);
|
|
1252
|
+
}
|
|
915
1253
|
}
|
|
916
1254
|
if (forkBinding) {
|
|
917
|
-
this.opts.client
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1255
|
+
this.opts.client
|
|
1256
|
+
.updateAgentSession(
|
|
1257
|
+
this.opts.config.org_id,
|
|
1258
|
+
this.opts.agentUserId,
|
|
1259
|
+
forkBinding.agentSessionId,
|
|
1260
|
+
{ status: 'closed' },
|
|
1261
|
+
)
|
|
1262
|
+
.catch(() => {});
|
|
921
1263
|
this.sessionBindings.delete(fork.fork.sessionKey);
|
|
922
1264
|
}
|
|
923
1265
|
}
|
|
@@ -947,20 +1289,31 @@ export class ParallAgentGateway {
|
|
|
947
1289
|
|
|
948
1290
|
const event = events[events.length - 1];
|
|
949
1291
|
const earlier = events.slice(0, -1);
|
|
950
|
-
const hasPendingInjections =
|
|
951
|
-
|
|
952
|
-
const pendingFork = hasPendingInjections
|
|
1292
|
+
const hasPendingInjections =
|
|
1293
|
+
this.opts.dispatchAdapter.hasPendingInjections?.(this.opts.runtimeKey) ?? false;
|
|
1294
|
+
const pendingFork = hasPendingInjections
|
|
1295
|
+
? []
|
|
1296
|
+
: this.dispatchState.pendingForkResults.splice(0);
|
|
953
1297
|
const forkPrefix = buildForkResultPrefix(pendingFork);
|
|
954
1298
|
this.dispatchState.mainCurrentTargetId = event.targetId;
|
|
955
|
-
this.dispatchState.mainPreDispatchBranchPoint =
|
|
956
|
-
this.opts.
|
|
957
|
-
|
|
1299
|
+
this.dispatchState.mainPreDispatchBranchPoint = this.opts.dispatchAdapter.getBranchPoint?.(
|
|
1300
|
+
this.opts.runtimeKey,
|
|
1301
|
+
);
|
|
1302
|
+
try {
|
|
1303
|
+
await this.emitDispatchReceived(event);
|
|
1304
|
+
} catch (err) {
|
|
1305
|
+
this.opts.log?.warn?.(
|
|
1306
|
+
`mark-received failed for buffered dispatch, leaving unacked for retry: ${String(err)}`,
|
|
1307
|
+
);
|
|
1308
|
+
this.dispatchState.mainBuffer.unshift(...events);
|
|
1309
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1310
|
+
break;
|
|
1311
|
+
}
|
|
1312
|
+
const dispatched = await this.runDispatch(
|
|
958
1313
|
event,
|
|
959
1314
|
this.opts.runtimeKey,
|
|
960
1315
|
forkPrefix + buildEventBody(event),
|
|
961
1316
|
earlier,
|
|
962
|
-
undefined,
|
|
963
|
-
{ suppressStart: injectedTypingCount > 0, injectedTypingCount },
|
|
964
1317
|
);
|
|
965
1318
|
if (!dispatched) {
|
|
966
1319
|
// Shutdown: skip the ack so the server redelivers these buffered
|
|
@@ -971,12 +1324,16 @@ export class ParallAgentGateway {
|
|
|
971
1324
|
break;
|
|
972
1325
|
}
|
|
973
1326
|
for (const bufferedEvent of events) {
|
|
974
|
-
const sourceType =
|
|
1327
|
+
const sourceType =
|
|
1328
|
+
bufferedEvent.ackSourceType ??
|
|
1329
|
+
(bufferedEvent.type === 'task' ? 'task_activity' : 'message');
|
|
975
1330
|
const sourceId = bufferedEvent.ackSourceId ?? bufferedEvent.messageId;
|
|
976
|
-
this.opts.client
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1331
|
+
this.opts.client
|
|
1332
|
+
.ackDispatch(this.opts.config.org_id, {
|
|
1333
|
+
source_type: sourceType,
|
|
1334
|
+
source_id: sourceId,
|
|
1335
|
+
})
|
|
1336
|
+
.catch(() => {});
|
|
980
1337
|
}
|
|
981
1338
|
}
|
|
982
1339
|
} finally {
|
|
@@ -984,6 +1341,18 @@ export class ParallAgentGateway {
|
|
|
984
1341
|
this.dispatchState.mainDispatching = false;
|
|
985
1342
|
this.dispatchState.mainCurrentTargetId = undefined;
|
|
986
1343
|
this.dispatchState.mainPreDispatchBranchPoint = undefined;
|
|
1344
|
+
if (!this.shuttingDown && this.dispatchState.mainBuffer.length > 0) {
|
|
1345
|
+
setTimeout(() => {
|
|
1346
|
+
if (
|
|
1347
|
+
!this.draining &&
|
|
1348
|
+
!this.dispatchState.mainDispatching &&
|
|
1349
|
+
this.dispatchState.mainBuffer.length > 0
|
|
1350
|
+
) {
|
|
1351
|
+
this.dispatchState.mainDispatching = true;
|
|
1352
|
+
void this.drainMainBuffer();
|
|
1353
|
+
}
|
|
1354
|
+
}, 5000);
|
|
1355
|
+
}
|
|
987
1356
|
}
|
|
988
1357
|
}
|
|
989
1358
|
|
|
@@ -991,7 +1360,7 @@ export class ParallAgentGateway {
|
|
|
991
1360
|
const disposition = routeTrigger(event, this.dispatchState);
|
|
992
1361
|
|
|
993
1362
|
switch (disposition.action) {
|
|
994
|
-
case
|
|
1363
|
+
case 'main': {
|
|
995
1364
|
const pendingFork = this.dispatchState.pendingForkResults.splice(0);
|
|
996
1365
|
const forkPrefix = buildForkResultPrefix(pendingFork);
|
|
997
1366
|
this.dispatchState.mainDispatching = true;
|
|
@@ -999,11 +1368,26 @@ export class ParallAgentGateway {
|
|
|
999
1368
|
// Snapshot the on-disk branch point BEFORE runDispatch starts writing
|
|
1000
1369
|
// to the session file. Fork sessions created while main is in-flight
|
|
1001
1370
|
// use this to branch from the clean pre-dispatch state.
|
|
1002
|
-
this.dispatchState.mainPreDispatchBranchPoint =
|
|
1003
|
-
this.opts.
|
|
1371
|
+
this.dispatchState.mainPreDispatchBranchPoint = this.opts.dispatchAdapter.getBranchPoint?.(
|
|
1372
|
+
this.opts.runtimeKey,
|
|
1373
|
+
);
|
|
1374
|
+
try {
|
|
1375
|
+
await this.emitDispatchReceived(event);
|
|
1376
|
+
} catch (err) {
|
|
1377
|
+
this.opts.log?.warn?.(`mark-received failed, leaving unacked for retry: ${String(err)}`);
|
|
1378
|
+
this.dispatchState.mainDispatching = false;
|
|
1379
|
+
this.dispatchState.mainCurrentTargetId = undefined;
|
|
1380
|
+
this.dispatchState.mainPreDispatchBranchPoint = undefined;
|
|
1381
|
+
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1382
|
+
return false;
|
|
1383
|
+
}
|
|
1004
1384
|
let dispatched = false;
|
|
1005
1385
|
try {
|
|
1006
|
-
dispatched = await this.
|
|
1386
|
+
dispatched = await this.runDispatch(
|
|
1387
|
+
event,
|
|
1388
|
+
this.opts.runtimeKey,
|
|
1389
|
+
forkPrefix + buildEventBody(event),
|
|
1390
|
+
);
|
|
1007
1391
|
if (!dispatched) {
|
|
1008
1392
|
// Shutdown short-circuit — restore the fork results so a future
|
|
1009
1393
|
// pod can replay them, and return false so handleMessage skips ack.
|
|
@@ -1015,7 +1399,7 @@ export class ParallAgentGateway {
|
|
|
1015
1399
|
return dispatched;
|
|
1016
1400
|
}
|
|
1017
1401
|
|
|
1018
|
-
case
|
|
1402
|
+
case 'buffer-main': {
|
|
1019
1403
|
if (this.shuttingDown) {
|
|
1020
1404
|
return false;
|
|
1021
1405
|
}
|
|
@@ -1025,24 +1409,28 @@ export class ParallAgentGateway {
|
|
|
1025
1409
|
this.dispatchState.mainBuffer.push(event);
|
|
1026
1410
|
if (
|
|
1027
1411
|
this.dispatchState.mainCurrentTargetId === event.targetId &&
|
|
1028
|
-
await this.opts.dispatchAdapter.enqueueDuringDispatch?.(
|
|
1412
|
+
(await this.opts.dispatchAdapter.enqueueDuringDispatch?.(
|
|
1413
|
+
this.opts.runtimeKey,
|
|
1414
|
+
buildEventBody(event),
|
|
1415
|
+
))
|
|
1029
1416
|
) {
|
|
1030
|
-
this.
|
|
1031
|
-
this.opts.log?.info(
|
|
1032
|
-
`steer injected for ${event.messageId} (will drain for bookkeeping)`,
|
|
1033
|
-
);
|
|
1417
|
+
this.opts.log?.info(`steer injected for ${event.messageId} (will drain for bookkeeping)`);
|
|
1034
1418
|
}
|
|
1035
1419
|
// If the main dispatch cycle ended while we awaited the steer RPC,
|
|
1036
1420
|
// our event is buffered but no drain is in flight. Re-enter the
|
|
1037
1421
|
// drain to process it. The draining guard prevents re-entry.
|
|
1038
|
-
if (
|
|
1422
|
+
if (
|
|
1423
|
+
!this.dispatchState.mainDispatching &&
|
|
1424
|
+
!this.draining &&
|
|
1425
|
+
this.dispatchState.mainBuffer.length > 0
|
|
1426
|
+
) {
|
|
1039
1427
|
this.dispatchState.mainDispatching = true;
|
|
1040
1428
|
void this.drainMainBuffer();
|
|
1041
1429
|
}
|
|
1042
1430
|
return false;
|
|
1043
1431
|
}
|
|
1044
1432
|
|
|
1045
|
-
case
|
|
1433
|
+
case 'buffer-fork': {
|
|
1046
1434
|
const activeFork = this.forkStates.get(event.targetId);
|
|
1047
1435
|
if (!activeFork) {
|
|
1048
1436
|
this.dispatchState.mainBuffer.push(event);
|
|
@@ -1053,12 +1441,21 @@ export class ParallAgentGateway {
|
|
|
1053
1441
|
});
|
|
1054
1442
|
}
|
|
1055
1443
|
|
|
1056
|
-
case
|
|
1444
|
+
case 'new-fork': {
|
|
1057
1445
|
if (!this.opts.dispatchAdapter.forkSession) {
|
|
1058
1446
|
this.dispatchState.mainBuffer.push(event);
|
|
1059
1447
|
return false;
|
|
1060
1448
|
}
|
|
1061
1449
|
|
|
1450
|
+
try {
|
|
1451
|
+
await this.emitDispatchReceived(event);
|
|
1452
|
+
} catch (err) {
|
|
1453
|
+
this.opts.log?.warn?.(
|
|
1454
|
+
`mark-received failed for fork dispatch, leaving unacked for retry: ${String(err)}`,
|
|
1455
|
+
);
|
|
1456
|
+
this.dispatchState.mainBuffer.push(event);
|
|
1457
|
+
return false;
|
|
1458
|
+
}
|
|
1062
1459
|
const fork = await this.opts.dispatchAdapter.forkSession({
|
|
1063
1460
|
sessionKey: this.opts.runtimeKey,
|
|
1064
1461
|
context: this.buildDispatchContext(event, this.opts.runtimeKey),
|
|
@@ -1076,10 +1473,16 @@ export class ParallAgentGateway {
|
|
|
1076
1473
|
targetId: event.targetId,
|
|
1077
1474
|
queue: [],
|
|
1078
1475
|
processedEvents: [],
|
|
1476
|
+
deadlineTimer: null,
|
|
1477
|
+
deadlineExceeded: false,
|
|
1079
1478
|
};
|
|
1080
1479
|
this.forkStates.set(event.targetId, activeFork);
|
|
1081
1480
|
this.dispatchState.activeForks.set(event.targetId, fork.sessionKey);
|
|
1082
1481
|
|
|
1482
|
+
activeFork.deadlineTimer = setTimeout(() => {
|
|
1483
|
+
this.abortFork(event.targetId, `deadline exceeded (${this.FORK_DEADLINE_MS}ms)`);
|
|
1484
|
+
}, this.FORK_DEADLINE_MS);
|
|
1485
|
+
|
|
1083
1486
|
const firstEventPromise = new Promise<boolean>((resolve) => {
|
|
1084
1487
|
activeFork.queue.push({ event, resolve });
|
|
1085
1488
|
});
|
|
@@ -1113,25 +1516,26 @@ export class ParallAgentGateway {
|
|
|
1113
1516
|
chatId: string,
|
|
1114
1517
|
message: DispatchableMessage,
|
|
1115
1518
|
): Promise<MessageDispatchDecision> {
|
|
1116
|
-
if (message.message_type !==
|
|
1117
|
-
return { action:
|
|
1519
|
+
if (message.message_type !== 'text') {
|
|
1520
|
+
return { action: 'skip' };
|
|
1118
1521
|
}
|
|
1119
1522
|
|
|
1120
1523
|
const chatInfo = await this.getOrFetchChatInfo(chatId);
|
|
1121
|
-
if (!chatInfo) return { action:
|
|
1524
|
+
if (!chatInfo) return { action: 'retry' };
|
|
1122
1525
|
|
|
1123
1526
|
const content = message.content as TextContent;
|
|
1124
|
-
const body = content.text?.trim() ??
|
|
1527
|
+
const body = content.text?.trim() ?? '';
|
|
1125
1528
|
const hasAttachments = message.attachments?.length;
|
|
1126
1529
|
|
|
1127
|
-
if (!body && !hasAttachments) return { action:
|
|
1530
|
+
if (!body && !hasAttachments) return { action: 'skip' };
|
|
1128
1531
|
|
|
1129
|
-
if (chatInfo.type ===
|
|
1532
|
+
if (chatInfo.type === 'group' && chatInfo.agentRoutingMode !== 'active') {
|
|
1130
1533
|
const mentions = content.mentions ?? [];
|
|
1131
1534
|
const isMentioned = mentions.some(
|
|
1132
|
-
(mention) =>
|
|
1535
|
+
(mention) =>
|
|
1536
|
+
mention.user_id === this.opts.agentUserId || mention.user_id === MENTION_ALL_USER_ID,
|
|
1133
1537
|
);
|
|
1134
|
-
if (!isMentioned) return { action:
|
|
1538
|
+
if (!isMentioned) return { action: 'skip' };
|
|
1135
1539
|
}
|
|
1136
1540
|
|
|
1137
1541
|
const attachments = (message.attachments ?? []).map((a) => ({
|
|
@@ -1141,23 +1545,57 @@ export class ParallAgentGateway {
|
|
|
1141
1545
|
mimeType: a.mime_type,
|
|
1142
1546
|
}));
|
|
1143
1547
|
|
|
1548
|
+
// Fetch unread context (best-effort, parallelized).
|
|
1549
|
+
let unreadCount: number | undefined;
|
|
1550
|
+
let unreadSince: string | undefined;
|
|
1551
|
+
let threadReplyCount: number | undefined;
|
|
1552
|
+
let threadUnreadCount: number | undefined;
|
|
1553
|
+
let threadUnreadSince: string | undefined;
|
|
1554
|
+
const isPassiveOrSmart = chatInfo.type === 'group' && chatInfo.agentRoutingMode !== 'active';
|
|
1555
|
+
const unreadPromise = isPassiveOrSmart
|
|
1556
|
+
? this.opts.client.getUnreadCounts(this.opts.config.org_id).catch(() => undefined)
|
|
1557
|
+
: Promise.resolve(undefined);
|
|
1558
|
+
const threadPromise = message.thread_root_id
|
|
1559
|
+
? this.opts.client
|
|
1560
|
+
.getThreadUnread(this.opts.config.org_id, chatId, message.thread_root_id)
|
|
1561
|
+
.catch(() => undefined)
|
|
1562
|
+
: Promise.resolve(undefined);
|
|
1563
|
+
const [counts, threadUnread] = await Promise.all([unreadPromise, threadPromise]);
|
|
1564
|
+
if (counts) {
|
|
1565
|
+
const entry = counts[chatId];
|
|
1566
|
+
if (entry && entry.count > 1) {
|
|
1567
|
+
unreadCount = entry.count;
|
|
1568
|
+
unreadSince = entry.since;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
if (threadUnread) {
|
|
1572
|
+
threadReplyCount = threadUnread.total;
|
|
1573
|
+
threadUnreadCount = threadUnread.unread > 0 ? threadUnread.unread : undefined;
|
|
1574
|
+
threadUnreadSince = threadUnread.since ?? undefined;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1144
1577
|
return {
|
|
1145
|
-
action:
|
|
1578
|
+
action: 'dispatch',
|
|
1146
1579
|
event: {
|
|
1147
|
-
type:
|
|
1580
|
+
type: 'message',
|
|
1148
1581
|
targetId: chatId,
|
|
1149
1582
|
targetName: chatInfo.name ?? undefined,
|
|
1150
1583
|
targetType: chatInfo.type,
|
|
1151
1584
|
senderId: message.sender_id,
|
|
1152
1585
|
senderName: message.sender?.display_name ?? message.sender_id,
|
|
1153
1586
|
messageId: message.id,
|
|
1154
|
-
body: body ||
|
|
1587
|
+
body: body || '[attachment]',
|
|
1155
1588
|
threadRootId: message.thread_root_id ?? undefined,
|
|
1156
1589
|
noReply: message.hints?.no_reply ?? false,
|
|
1157
1590
|
attachments: attachments.length > 0 ? attachments : undefined,
|
|
1158
1591
|
sentAt: message.created_at,
|
|
1159
|
-
ackSourceType:
|
|
1592
|
+
ackSourceType: 'message',
|
|
1160
1593
|
ackSourceId: message.id,
|
|
1594
|
+
unreadCount,
|
|
1595
|
+
unreadSince,
|
|
1596
|
+
threadReplyCount,
|
|
1597
|
+
threadUnreadCount,
|
|
1598
|
+
threadUnreadSince,
|
|
1161
1599
|
},
|
|
1162
1600
|
};
|
|
1163
1601
|
}
|
|
@@ -1166,11 +1604,11 @@ export class ParallAgentGateway {
|
|
|
1166
1604
|
if (this.shuttingDown) return; // drain window — let server requeue via catch-up
|
|
1167
1605
|
const chatId = data.chat_id;
|
|
1168
1606
|
if (data.sender_id === this.opts.agentUserId) return;
|
|
1169
|
-
if (data.message_type !==
|
|
1607
|
+
if (data.message_type !== 'text') return;
|
|
1170
1608
|
if (!this.tryClaimMessage(data.id)) return;
|
|
1171
1609
|
|
|
1172
1610
|
const decision = await this.buildMessageDispatchDecision(chatId, data);
|
|
1173
|
-
if (decision.action !==
|
|
1611
|
+
if (decision.action !== 'dispatch') {
|
|
1174
1612
|
this.dispatchedMessages.delete(data.id);
|
|
1175
1613
|
return;
|
|
1176
1614
|
}
|
|
@@ -1179,7 +1617,9 @@ export class ParallAgentGateway {
|
|
|
1179
1617
|
try {
|
|
1180
1618
|
const dispatched = await this.handleInboundEvent(event);
|
|
1181
1619
|
if (dispatched) {
|
|
1182
|
-
this.opts.client
|
|
1620
|
+
this.opts.client
|
|
1621
|
+
.ackDispatch(this.opts.config.org_id, { source_type: 'message', source_id: data.id })
|
|
1622
|
+
.catch(() => {});
|
|
1183
1623
|
} else {
|
|
1184
1624
|
this.dispatchedMessages.delete(data.id);
|
|
1185
1625
|
}
|
|
@@ -1203,19 +1643,19 @@ export class ParallAgentGateway {
|
|
|
1203
1643
|
parts.push(`Status: ${task.status}`, `Priority: ${task.priority}`);
|
|
1204
1644
|
if (task.project_id) parts.push(`Project: prll://${task.project_id}`);
|
|
1205
1645
|
if (task.parent_id) parts.push(`Parent: prll://${task.parent_id}`);
|
|
1206
|
-
if (task.description) parts.push(
|
|
1646
|
+
if (task.description) parts.push('', task.description);
|
|
1207
1647
|
|
|
1208
1648
|
const event: ParallEvent = {
|
|
1209
|
-
type:
|
|
1649
|
+
type: 'task',
|
|
1210
1650
|
targetId: task.id,
|
|
1211
1651
|
targetName: task.identifier ?? undefined,
|
|
1212
|
-
targetType:
|
|
1652
|
+
targetType: 'task',
|
|
1213
1653
|
senderId: task.creator_id,
|
|
1214
|
-
senderName:
|
|
1654
|
+
senderName: 'system',
|
|
1215
1655
|
messageId: task.id,
|
|
1216
|
-
body: parts.join(
|
|
1656
|
+
body: parts.join('\n'),
|
|
1217
1657
|
sentAt: task.updated_at ?? task.created_at,
|
|
1218
|
-
ackSourceType:
|
|
1658
|
+
ackSourceType: 'task_activity',
|
|
1219
1659
|
ackSourceId,
|
|
1220
1660
|
};
|
|
1221
1661
|
|
|
@@ -1288,10 +1728,12 @@ export class ParallAgentGateway {
|
|
|
1288
1728
|
let task: Task | null = null;
|
|
1289
1729
|
try {
|
|
1290
1730
|
task = await this.opts.client.getTask(this.opts.config.org_id, taskId);
|
|
1291
|
-
} catch {
|
|
1731
|
+
} catch {
|
|
1732
|
+
/* task context is optional */
|
|
1733
|
+
}
|
|
1292
1734
|
|
|
1293
1735
|
const taskLabel = task ? `${task.identifier ?? task.id} "${task.title}"` : taskId;
|
|
1294
|
-
this.opts.log?.info(`task comment on ${taskLabel} by ${actorId ??
|
|
1736
|
+
this.opts.log?.info(`task comment on ${taskLabel} by ${actorId ?? 'unknown'}`);
|
|
1295
1737
|
|
|
1296
1738
|
const parts: string[] = [];
|
|
1297
1739
|
if (task) {
|
|
@@ -1300,20 +1742,97 @@ export class ParallAgentGateway {
|
|
|
1300
1742
|
} else {
|
|
1301
1743
|
parts.push(`Task: prll://${taskId}`);
|
|
1302
1744
|
}
|
|
1303
|
-
parts.push(
|
|
1304
|
-
|
|
1745
|
+
parts.push(
|
|
1746
|
+
`Comment by: ${comment.author?.display_name ?? actorId ?? 'unknown'} (prll://${comment.author_id})`,
|
|
1747
|
+
);
|
|
1748
|
+
parts.push('', comment.body);
|
|
1305
1749
|
|
|
1306
1750
|
const event: ParallEvent = {
|
|
1307
|
-
type:
|
|
1751
|
+
type: 'task_comment',
|
|
1308
1752
|
targetId: taskId,
|
|
1309
1753
|
targetName: task?.identifier ?? undefined,
|
|
1310
|
-
targetType:
|
|
1754
|
+
targetType: 'task',
|
|
1311
1755
|
senderId: comment.author_id,
|
|
1312
|
-
senderName: comment.author?.display_name ?? actorId ??
|
|
1756
|
+
senderName: comment.author?.display_name ?? actorId ?? 'unknown',
|
|
1313
1757
|
messageId: commentId,
|
|
1314
|
-
body: parts.join(
|
|
1758
|
+
body: parts.join('\n'),
|
|
1315
1759
|
deliveryReason: deliveryReason ?? undefined,
|
|
1316
|
-
ackSourceType:
|
|
1760
|
+
ackSourceType: 'comment',
|
|
1761
|
+
ackSourceId: commentId,
|
|
1762
|
+
};
|
|
1763
|
+
|
|
1764
|
+
let dispatched: boolean;
|
|
1765
|
+
try {
|
|
1766
|
+
dispatched = await this.handleInboundEvent(event);
|
|
1767
|
+
} catch (err) {
|
|
1768
|
+
// Clear dedupe key so the event remains retryable on next catch-up.
|
|
1769
|
+
this.dispatchedTasks.delete(dedupeKey);
|
|
1770
|
+
throw err;
|
|
1771
|
+
}
|
|
1772
|
+
if (!dispatched) {
|
|
1773
|
+
this.dispatchedTasks.delete(dedupeKey);
|
|
1774
|
+
}
|
|
1775
|
+
return dispatched;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
private async handleWikiComment(
|
|
1779
|
+
commentId: string,
|
|
1780
|
+
actorId: string | null,
|
|
1781
|
+
deliveryReason?: DispatchDeliveryReason | null,
|
|
1782
|
+
): Promise<boolean> {
|
|
1783
|
+
if (this.shuttingDown) return false; // drain window — let server requeue via catch-up
|
|
1784
|
+
// Shares the comment dedupe namespace with handleTaskComment; comment IDs
|
|
1785
|
+
// are globally unique so wiki/task keys never collide.
|
|
1786
|
+
const dedupeKey = `comment:${commentId}`;
|
|
1787
|
+
if (this.dispatchedTasks.has(dedupeKey)) return false;
|
|
1788
|
+
this.dispatchedTasks.add(dedupeKey);
|
|
1789
|
+
|
|
1790
|
+
let comment: Comment | null = null;
|
|
1791
|
+
try {
|
|
1792
|
+
comment = await this.opts.client.getComment(this.opts.config.org_id, commentId);
|
|
1793
|
+
} catch (err: unknown) {
|
|
1794
|
+
const status = (err as { status?: number })?.status;
|
|
1795
|
+
// 404 (deleted) and 403 (this agent lacks wiki read access to the
|
|
1796
|
+
// target) are both permanent for this dispatch — ack the stale delivery
|
|
1797
|
+
// so it doesn't retry on every catch-up. Other errors are transient.
|
|
1798
|
+
if (status === 404 || status === 403) {
|
|
1799
|
+
this.opts.log?.info(
|
|
1800
|
+
`skipping inaccessible wiki comment ${commentId} (status ${status}), acking stale dispatch`,
|
|
1801
|
+
);
|
|
1802
|
+
this.dispatchedTasks.delete(dedupeKey);
|
|
1803
|
+
return true; // caller will ack
|
|
1804
|
+
}
|
|
1805
|
+
this.dispatchedTasks.delete(dedupeKey);
|
|
1806
|
+
return false; // transient error — leave pending for retry
|
|
1807
|
+
}
|
|
1808
|
+
if (!comment) {
|
|
1809
|
+
this.dispatchedTasks.delete(dedupeKey);
|
|
1810
|
+
return true; // null response = gone, ack stale dispatch
|
|
1811
|
+
}
|
|
1812
|
+
if (comment.hints?.no_reply) {
|
|
1813
|
+
this.opts.log?.info(`skipping no_reply wiki comment ${commentId}, acking stale dispatch`);
|
|
1814
|
+
this.dispatchedTasks.delete(dedupeKey);
|
|
1815
|
+
return true;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
const target = parseWikiCommentTarget(comment.target_uri);
|
|
1819
|
+
this.opts.log?.info(`wiki comment on ${target.label} by ${actorId ?? 'unknown'}`);
|
|
1820
|
+
|
|
1821
|
+
const event: ParallEvent = {
|
|
1822
|
+
type: 'wiki_comment',
|
|
1823
|
+
// Full target_uri (scheme-stripped) is the routing key so different
|
|
1824
|
+
// pages / inline anchors / changesets in the same wiki don't collide on
|
|
1825
|
+
// one gateway lane. replyTargetUri keeps the canonical prll:// form.
|
|
1826
|
+
targetId: target.routingKey,
|
|
1827
|
+
targetName: target.label,
|
|
1828
|
+
targetType: target.targetType,
|
|
1829
|
+
senderId: comment.author_id,
|
|
1830
|
+
senderName: comment.author?.display_name ?? actorId ?? 'unknown',
|
|
1831
|
+
messageId: commentId,
|
|
1832
|
+
body: comment.body,
|
|
1833
|
+
deliveryReason: deliveryReason ?? undefined,
|
|
1834
|
+
replyTargetUri: comment.target_uri,
|
|
1835
|
+
ackSourceType: 'comment',
|
|
1317
1836
|
ackSourceId: commentId,
|
|
1318
1837
|
};
|
|
1319
1838
|
|
|
@@ -1339,7 +1858,10 @@ export class ParallAgentGateway {
|
|
|
1339
1858
|
* access to already-delivered run snapshots, and the runtime must not crash
|
|
1340
1859
|
* or retry forever in that case.
|
|
1341
1860
|
*/
|
|
1342
|
-
private async fetchAndHandleScheduleFire(
|
|
1861
|
+
private async fetchAndHandleScheduleFire(
|
|
1862
|
+
runId: string,
|
|
1863
|
+
actorId: string | null,
|
|
1864
|
+
): Promise<boolean> {
|
|
1343
1865
|
let run: ScheduleRun | null = null;
|
|
1344
1866
|
try {
|
|
1345
1867
|
run = await this.opts.client.getScheduleRun(this.opts.config.org_id, runId);
|
|
@@ -1354,7 +1876,9 @@ export class ParallAgentGateway {
|
|
|
1354
1876
|
this.opts.log?.warn(`schedule run ${runId} not accessible (404), acking stale dispatch`);
|
|
1355
1877
|
return true;
|
|
1356
1878
|
}
|
|
1357
|
-
this.opts.log?.warn(
|
|
1879
|
+
this.opts.log?.warn(
|
|
1880
|
+
`schedule run fetch failed for ${runId}, leaving pending: ${String(err)}`,
|
|
1881
|
+
);
|
|
1358
1882
|
return false;
|
|
1359
1883
|
}
|
|
1360
1884
|
if (!run) return true;
|
|
@@ -1369,20 +1893,20 @@ export class ParallAgentGateway {
|
|
|
1369
1893
|
this.opts.log?.info(`schedule fired: ${run.id} (schedule ${run.schedule_id})`);
|
|
1370
1894
|
|
|
1371
1895
|
const event: ParallEvent = {
|
|
1372
|
-
type:
|
|
1896
|
+
type: 'schedule',
|
|
1373
1897
|
// Route by schedule_id (not attached chat_id) so concurrent fires of
|
|
1374
1898
|
// different schedules can fork independently — matches the PR1 primitive
|
|
1375
1899
|
// design where "schedule triggers; target decides response" and fire
|
|
1376
1900
|
// semantics are independent of any attached conversation.
|
|
1377
1901
|
targetId: run.schedule_id,
|
|
1378
|
-
targetType:
|
|
1379
|
-
senderId: actorId ??
|
|
1380
|
-
senderName:
|
|
1902
|
+
targetType: 'schedule',
|
|
1903
|
+
senderId: actorId ?? 'system',
|
|
1904
|
+
senderName: 'schedule',
|
|
1381
1905
|
messageId: run.id,
|
|
1382
|
-
body: run.fired_description ??
|
|
1906
|
+
body: run.fired_description ?? '',
|
|
1383
1907
|
scheduledFireAt: run.scheduled_fire_at,
|
|
1384
1908
|
attachedUri: run.fired_attached_uri ?? undefined,
|
|
1385
|
-
ackSourceType:
|
|
1909
|
+
ackSourceType: 'schedule_run',
|
|
1386
1910
|
ackSourceId: run.id,
|
|
1387
1911
|
};
|
|
1388
1912
|
|
|
@@ -1399,17 +1923,25 @@ export class ParallAgentGateway {
|
|
|
1399
1923
|
return dispatched;
|
|
1400
1924
|
}
|
|
1401
1925
|
|
|
1402
|
-
private async fetchAndHandleApprovalDecided(
|
|
1926
|
+
private async fetchAndHandleApprovalDecided(
|
|
1927
|
+
approvalId: string,
|
|
1928
|
+
actorId: string | null,
|
|
1929
|
+
chatId: string | null,
|
|
1930
|
+
): Promise<boolean> {
|
|
1403
1931
|
let approval: Approval | null = null;
|
|
1404
1932
|
try {
|
|
1405
1933
|
approval = await this.opts.client.getApproval(approvalId);
|
|
1406
1934
|
} catch (err: unknown) {
|
|
1407
1935
|
const status = (err as { status?: number })?.status;
|
|
1408
1936
|
if (status === 404 || status === 403) {
|
|
1409
|
-
this.opts.log?.warn(
|
|
1937
|
+
this.opts.log?.warn(
|
|
1938
|
+
`approval ${approvalId} not accessible (${status}), acking stale dispatch`,
|
|
1939
|
+
);
|
|
1410
1940
|
return true;
|
|
1411
1941
|
}
|
|
1412
|
-
this.opts.log?.warn(
|
|
1942
|
+
this.opts.log?.warn(
|
|
1943
|
+
`approval fetch failed for ${approvalId}, leaving pending: ${String(err)}`,
|
|
1944
|
+
);
|
|
1413
1945
|
return false;
|
|
1414
1946
|
}
|
|
1415
1947
|
if (!approval) return true;
|
|
@@ -1420,15 +1952,15 @@ export class ParallAgentGateway {
|
|
|
1420
1952
|
this.dispatchedTasks.add(dedupeKey);
|
|
1421
1953
|
this.opts.log?.info(`approval decided: ${approval.id} (${approval.status})`);
|
|
1422
1954
|
|
|
1423
|
-
const statusLabel = approval.status ===
|
|
1424
|
-
const execInfo = approval.execution_status ? ` | execution: ${approval.execution_status}` :
|
|
1955
|
+
const statusLabel = approval.status === 'approved' ? 'Approved' : 'Rejected';
|
|
1956
|
+
const execInfo = approval.execution_status ? ` | execution: ${approval.execution_status}` : '';
|
|
1425
1957
|
const body = `${statusLabel}: ${approval.title}${execInfo}`;
|
|
1426
1958
|
|
|
1427
1959
|
const event: ParallEvent = {
|
|
1428
|
-
type:
|
|
1960
|
+
type: 'approval',
|
|
1429
1961
|
targetId: chatId ?? approval.chat_id,
|
|
1430
|
-
senderId: actorId ?? approval.decided_by ??
|
|
1431
|
-
senderName:
|
|
1962
|
+
senderId: actorId ?? approval.decided_by ?? 'system',
|
|
1963
|
+
senderName: 'approver',
|
|
1432
1964
|
messageId: approval.id,
|
|
1433
1965
|
body,
|
|
1434
1966
|
};
|
|
@@ -1446,11 +1978,9 @@ export class ParallAgentGateway {
|
|
|
1446
1978
|
return dispatched;
|
|
1447
1979
|
}
|
|
1448
1980
|
|
|
1449
|
-
private async catchUpFromDispatch(
|
|
1450
|
-
const minAge = coldStart ? Date.now() - this.COLD_START_WINDOW_MS : 0;
|
|
1981
|
+
private async catchUpFromDispatch() {
|
|
1451
1982
|
let cursor: string | undefined;
|
|
1452
1983
|
let processed = 0;
|
|
1453
|
-
let skippedOld = 0;
|
|
1454
1984
|
|
|
1455
1985
|
do {
|
|
1456
1986
|
const page = await this.opts.client.getDispatch(this.opts.config.org_id, {
|
|
@@ -1462,36 +1992,57 @@ export class ParallAgentGateway {
|
|
|
1462
1992
|
// Shutdown short-circuit: stop fetching/building work that runDispatch
|
|
1463
1993
|
// will only reject. Items remain unacked for the replacement pod.
|
|
1464
1994
|
if (this.shuttingDown) break;
|
|
1465
|
-
if (minAge > 0 && new Date(item.created_at).getTime() < minAge) {
|
|
1466
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
|
|
1467
|
-
skippedOld++;
|
|
1468
|
-
continue;
|
|
1469
|
-
}
|
|
1470
1995
|
|
|
1471
1996
|
processed++;
|
|
1472
1997
|
try {
|
|
1473
1998
|
let dispatched = false;
|
|
1474
|
-
if (item.event_type ===
|
|
1999
|
+
if (item.event_type === 'task_assign' && item.task_id) {
|
|
1475
2000
|
try {
|
|
1476
|
-
dispatched = await this.handleTaskDispatch(
|
|
2001
|
+
dispatched = await this.handleTaskDispatch(
|
|
2002
|
+
item.task_id,
|
|
2003
|
+
item.source_id ?? item.task_id,
|
|
2004
|
+
);
|
|
1477
2005
|
} catch (err: unknown) {
|
|
1478
|
-
this.opts.log?.warn(
|
|
2006
|
+
this.opts.log?.warn(
|
|
2007
|
+
`catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`,
|
|
2008
|
+
);
|
|
1479
2009
|
continue;
|
|
1480
2010
|
}
|
|
1481
|
-
} else if (item.event_type ===
|
|
2011
|
+
} else if (item.event_type === 'task_update' && item.task_id) {
|
|
1482
2012
|
try {
|
|
1483
|
-
dispatched = await this.handleTaskDispatch(
|
|
2013
|
+
dispatched = await this.handleTaskDispatch(
|
|
2014
|
+
item.task_id,
|
|
2015
|
+
item.source_id ?? item.task_id,
|
|
2016
|
+
{ allowCreator: true },
|
|
2017
|
+
);
|
|
1484
2018
|
} catch (err: unknown) {
|
|
1485
|
-
this.opts.log?.warn(
|
|
2019
|
+
this.opts.log?.warn(
|
|
2020
|
+
`catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`,
|
|
2021
|
+
);
|
|
1486
2022
|
continue;
|
|
1487
2023
|
}
|
|
1488
|
-
} else if (item.event_type ===
|
|
1489
|
-
dispatched = await this.handleTaskComment(
|
|
1490
|
-
|
|
2024
|
+
} else if (item.event_type === 'task_comment' && item.source_id && item.task_id) {
|
|
2025
|
+
dispatched = await this.handleTaskComment(
|
|
2026
|
+
item.source_id,
|
|
2027
|
+
item.task_id,
|
|
2028
|
+
item.actor_id,
|
|
2029
|
+
item.delivery_reason,
|
|
2030
|
+
);
|
|
2031
|
+
} else if (item.event_type === 'wiki_comment' && item.source_id) {
|
|
2032
|
+
dispatched = await this.handleWikiComment(
|
|
2033
|
+
item.source_id,
|
|
2034
|
+
item.actor_id,
|
|
2035
|
+
item.delivery_reason,
|
|
2036
|
+
);
|
|
2037
|
+
} else if (item.event_type === 'schedule.fire' && item.source_id) {
|
|
1491
2038
|
dispatched = await this.fetchAndHandleScheduleFire(item.source_id, item.actor_id);
|
|
1492
|
-
} else if (item.event_type ===
|
|
1493
|
-
dispatched = await this.fetchAndHandleApprovalDecided(
|
|
1494
|
-
|
|
2039
|
+
} else if (item.event_type === 'approval_decided' && item.source_id) {
|
|
2040
|
+
dispatched = await this.fetchAndHandleApprovalDecided(
|
|
2041
|
+
item.source_id,
|
|
2042
|
+
item.actor_id,
|
|
2043
|
+
item.chat_id ?? null,
|
|
2044
|
+
);
|
|
2045
|
+
} else if (item.event_type === 'message' && item.source_id && item.chat_id) {
|
|
1495
2046
|
if (!this.tryClaimMessage(item.source_id)) continue;
|
|
1496
2047
|
let msg: Awaited<ReturnType<typeof this.opts.client.getMessage>> | null = null;
|
|
1497
2048
|
let msgFetchFailed = false;
|
|
@@ -1503,7 +2054,9 @@ export class ParallAgentGateway {
|
|
|
1503
2054
|
msg = null;
|
|
1504
2055
|
} else {
|
|
1505
2056
|
msgFetchFailed = true;
|
|
1506
|
-
this.opts.log?.warn(
|
|
2057
|
+
this.opts.log?.warn(
|
|
2058
|
+
`catch-up message fetch failed for ${item.source_id}, leaving pending: ${String(err)}`,
|
|
2059
|
+
);
|
|
1507
2060
|
}
|
|
1508
2061
|
}
|
|
1509
2062
|
if (msgFetchFailed) {
|
|
@@ -1517,11 +2070,11 @@ export class ParallAgentGateway {
|
|
|
1517
2070
|
}
|
|
1518
2071
|
|
|
1519
2072
|
const decision = await this.buildMessageDispatchDecision(item.chat_id, msg);
|
|
1520
|
-
if (decision.action ===
|
|
2073
|
+
if (decision.action === 'retry') {
|
|
1521
2074
|
this.dispatchedMessages.delete(item.source_id);
|
|
1522
2075
|
continue;
|
|
1523
2076
|
}
|
|
1524
|
-
if (decision.action ===
|
|
2077
|
+
if (decision.action === 'skip') {
|
|
1525
2078
|
this.dispatchedMessages.delete(item.source_id);
|
|
1526
2079
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
|
|
1527
2080
|
continue;
|
|
@@ -1533,7 +2086,9 @@ export class ParallAgentGateway {
|
|
|
1533
2086
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
|
|
1534
2087
|
}
|
|
1535
2088
|
} catch (err) {
|
|
1536
|
-
this.opts.log?.warn(
|
|
2089
|
+
this.opts.log?.warn(
|
|
2090
|
+
`catch-up dispatch ${item.id} (${item.event_type}) failed: ${String(err)}`,
|
|
2091
|
+
);
|
|
1537
2092
|
}
|
|
1538
2093
|
}
|
|
1539
2094
|
// Stop paginating once shutdown begins — the inner loop already broke,
|
|
@@ -1541,14 +2096,21 @@ export class ParallAgentGateway {
|
|
|
1541
2096
|
cursor = !this.shuttingDown && page.has_more ? page.next_cursor : undefined;
|
|
1542
2097
|
} while (cursor);
|
|
1543
2098
|
|
|
1544
|
-
if (processed > 0
|
|
1545
|
-
this.opts.log?.info(`dispatch catch-up: processed ${processed}
|
|
2099
|
+
if (processed > 0) {
|
|
2100
|
+
this.opts.log?.info(`dispatch catch-up: processed ${processed}`);
|
|
1546
2101
|
}
|
|
1547
2102
|
}
|
|
1548
2103
|
|
|
1549
2104
|
private async handleHello(data: HelloData) {
|
|
1550
2105
|
const { client, config, log } = this.opts;
|
|
1551
|
-
this.sessionId = data.session_id ??
|
|
2106
|
+
this.sessionId = data.session_id ?? '';
|
|
2107
|
+
if (this.forkStates.size > 0) {
|
|
2108
|
+
const targetIds = [...this.forkStates.keys()];
|
|
2109
|
+
log?.info(`aborting ${targetIds.length} active fork(s) on reconnect`);
|
|
2110
|
+
for (const targetId of targetIds) {
|
|
2111
|
+
this.abortFork(targetId, 'ws reconnect');
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
1552
2114
|
const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
|
|
1553
2115
|
try {
|
|
1554
2116
|
const count = await fetchAllChats(client, config.org_id, this.chatInfoMap);
|
|
@@ -1570,7 +2132,7 @@ export class ParallAgentGateway {
|
|
|
1570
2132
|
log?.warn(`heartbeat drift ${drift}ms — event loop may be blocked`);
|
|
1571
2133
|
}
|
|
1572
2134
|
this.lastHeartbeatAt = now;
|
|
1573
|
-
if (this.opts.ws.state !==
|
|
2135
|
+
if (this.opts.ws.state !== 'connected') return;
|
|
1574
2136
|
this.opts.ws.sendAgentHeartbeat(this.sessionId, {
|
|
1575
2137
|
hostname: os.hostname(),
|
|
1576
2138
|
cores: os.cpus().length,
|
|
@@ -1580,9 +2142,7 @@ export class ParallAgentGateway {
|
|
|
1580
2142
|
});
|
|
1581
2143
|
}, intervalSec * 1000);
|
|
1582
2144
|
|
|
1583
|
-
|
|
1584
|
-
this.hadSuccessfulHello = true;
|
|
1585
|
-
this.catchUpFromDispatch(isFirstHello).catch((err) => {
|
|
2145
|
+
this.catchUpFromDispatch().catch((err) => {
|
|
1586
2146
|
log?.warn(`dispatch catch-up failed: ${String(err)}`);
|
|
1587
2147
|
});
|
|
1588
2148
|
} catch (err) {
|
|
@@ -1634,10 +2194,6 @@ export class ParallAgentGateway {
|
|
|
1634
2194
|
}
|
|
1635
2195
|
|
|
1636
2196
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
1637
|
-
for (const [, dispatch] of this.activeDispatches) {
|
|
1638
|
-
clearInterval(dispatch.typingTimer);
|
|
1639
|
-
}
|
|
1640
|
-
this.activeDispatches.clear();
|
|
1641
2197
|
|
|
1642
2198
|
await this.opts.onBeforeDisconnect?.();
|
|
1643
2199
|
|