@parall/agent-core 1.31.0 → 1.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge-workspace.d.ts +1 -1
- package/dist/bridge-workspace.d.ts.map +1 -1
- package/dist/bridge-workspace.js +13 -13
- package/dist/dispatch-adapter.d.ts +15 -8
- package/dist/dispatch-adapter.d.ts.map +1 -1
- package/dist/event-format.d.ts +1 -1
- package/dist/event-format.d.ts.map +1 -1
- package/dist/event-format.js +68 -25
- package/dist/gateway-base.d.ts +14 -13
- package/dist/gateway-base.d.ts.map +1 -1
- package/dist/gateway-base.js +650 -313
- package/dist/index.d.ts +15 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -12
- package/dist/internal/attachment-input.d.ts +3 -3
- package/dist/internal/attachment-input.d.ts.map +1 -1
- package/dist/internal/attachment-input.js +61 -58
- package/dist/logger.d.ts +1 -1
- package/dist/platform-config.d.ts +28 -2
- package/dist/platform-config.d.ts.map +1 -1
- package/dist/platform-config.js +42 -11
- package/dist/prompt-fragments.d.ts +2 -2
- package/dist/prompt-fragments.d.ts.map +1 -1
- package/dist/prompt-fragments.js +37 -14
- package/dist/provider-config.d.ts +9 -0
- package/dist/provider-config.d.ts.map +1 -1
- package/dist/provider-config.js +13 -2
- package/dist/routing.d.ts +5 -5
- package/dist/routing.js +6 -6
- package/dist/session-state.d.ts +16 -0
- package/dist/session-state.d.ts.map +1 -1
- package/dist/session-state.js +45 -0
- package/dist/skills/index.d.ts +5 -4
- package/dist/skills/index.d.ts.map +1 -1
- package/dist/skills/index.js +28 -21
- package/dist/skills/parall-clips.d.ts +2 -0
- package/dist/skills/parall-clips.d.ts.map +1 -0
- package/dist/skills/parall-clips.js +44 -0
- package/dist/skills/parall-platform.d.ts +1 -1
- package/dist/skills/parall-platform.d.ts.map +1 -1
- package/dist/skills/parall-platform.js +6 -2
- package/dist/skills/parall-tasks.d.ts +1 -1
- package/dist/skills/parall-tasks.d.ts.map +1 -1
- package/dist/skills/parall-tasks.js +1 -1
- package/dist/skills/parall-wiki.d.ts +1 -1
- package/dist/skills/parall-wiki.d.ts.map +1 -1
- package/dist/skills/parall-wiki.js +1 -1
- 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 +13 -13
- package/src/dispatch-adapter.ts +31 -8
- package/src/event-format.ts +80 -30
- package/src/gateway-base.ts +988 -445
- package/src/index.ts +23 -13
- package/src/internal/attachment-input.ts +127 -100
- package/src/logger.ts +1 -1
- package/src/platform-config.ts +61 -16
- package/src/prompt-fragments.ts +37 -14
- package/src/provider-config.ts +14 -2
- package/src/routing.ts +11 -11
- package/src/session-state.ts +62 -0
- package/src/skills/index.ts +34 -23
- package/src/skills/parall-clips.ts +44 -0
- package/src/skills/parall-platform.ts +6 -2
- package/src/skills/parall-tasks.ts +1 -1
- package/src/skills/parall-wiki.ts +1 -1
- 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,30 +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';
|
|
42
66
|
|
|
43
|
-
const LIVE_SESSION_STATUSES = new Set([
|
|
67
|
+
const LIVE_SESSION_STATUSES = new Set(['open', 'active', 'idle']);
|
|
44
68
|
|
|
45
69
|
type ChatInfo = {
|
|
46
|
-
type: Chat[
|
|
70
|
+
type: Chat['type'];
|
|
47
71
|
name: string | null;
|
|
48
|
-
agentRoutingMode: Chat[
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
type ActiveDispatch = {
|
|
52
|
-
count: number;
|
|
53
|
-
typingTimer: ReturnType<typeof setInterval>;
|
|
72
|
+
agentRoutingMode: Chat['agent_routing_mode'];
|
|
54
73
|
};
|
|
55
74
|
|
|
56
75
|
type ForkQueueItem = {
|
|
@@ -63,6 +82,8 @@ type ActiveForkState = {
|
|
|
63
82
|
targetId: string;
|
|
64
83
|
queue: ForkQueueItem[];
|
|
65
84
|
processedEvents: ParallEvent[];
|
|
85
|
+
deadlineTimer: ReturnType<typeof setTimeout> | null;
|
|
86
|
+
deadlineExceeded: boolean;
|
|
66
87
|
};
|
|
67
88
|
|
|
68
89
|
type DispatchableMessage = {
|
|
@@ -78,9 +99,9 @@ type DispatchableMessage = {
|
|
|
78
99
|
};
|
|
79
100
|
|
|
80
101
|
type MessageDispatchDecision =
|
|
81
|
-
| { action:
|
|
82
|
-
| { action:
|
|
83
|
-
| { action:
|
|
102
|
+
| { action: 'dispatch'; event: ParallEvent }
|
|
103
|
+
| { action: 'skip' }
|
|
104
|
+
| { action: 'retry' };
|
|
84
105
|
|
|
85
106
|
export type ParallGatewayOptions = {
|
|
86
107
|
accountId: string;
|
|
@@ -98,17 +119,29 @@ export type ParallGatewayOptions = {
|
|
|
98
119
|
runtimeRef?: Record<string, unknown>;
|
|
99
120
|
dispatchAdapter: DispatchAdapter;
|
|
100
121
|
log?: GatewayLogger;
|
|
122
|
+
/** @deprecated No-op. Cold-start time filter has been removed to prevent silent dispatch loss. */
|
|
101
123
|
coldStartWindowMs?: number;
|
|
102
124
|
// Maximum time to wait for in-flight dispatches to finish after SIGTERM /
|
|
103
125
|
// abort before forcing WS disconnect. Pod termination grace period should
|
|
104
126
|
// be at least this + a few seconds for the remaining cleanup work.
|
|
105
127
|
shutdownDeadlineMs?: number;
|
|
128
|
+
forkDeadlineMs?: number;
|
|
129
|
+
dispatchDeadlineMs?: number;
|
|
106
130
|
contextFilePathForSession?: (sessionKey: string) => string | undefined;
|
|
107
131
|
/** @deprecated Use contextFilePathForSession. Kept for runtimes that haven't migrated. */
|
|
108
132
|
stepIdFilePathForSession?: (sessionKey: string) => string | undefined;
|
|
109
133
|
onConfigUpdate?: (data: AgentConfigUpdateData) => Promise<void> | void;
|
|
110
|
-
onSessionReady?: (state: {
|
|
111
|
-
|
|
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;
|
|
112
145
|
onBeforeDisconnect?: () => Promise<void> | void;
|
|
113
146
|
onNewSession?: (previousSessionId: string) => Promise<void> | void;
|
|
114
147
|
onSessionStale?: (sessionKey: string) => Promise<void> | void;
|
|
@@ -137,17 +170,74 @@ export function parseShutdownDeadlineMs(raw: string | undefined): number | undef
|
|
|
137
170
|
return Math.floor(n);
|
|
138
171
|
}
|
|
139
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
|
+
|
|
140
187
|
function resolveStepTarget(event: ParallEvent): { target_type: string; target_id?: string } {
|
|
141
|
-
if (event.type ===
|
|
142
|
-
return { target_type:
|
|
188
|
+
if (event.type === 'task' || event.targetId.startsWith('tsk_')) {
|
|
189
|
+
return { target_type: 'task', target_id: event.targetId };
|
|
143
190
|
}
|
|
144
|
-
if (event.targetId.startsWith(
|
|
145
|
-
return { target_type:
|
|
191
|
+
if (event.targetId.startsWith('cht_')) {
|
|
192
|
+
return { target_type: 'chat', target_id: event.targetId };
|
|
146
193
|
}
|
|
147
|
-
if (event.type ===
|
|
148
|
-
return { target_type:
|
|
194
|
+
if (event.type === 'schedule' || event.targetId.startsWith('sch_')) {
|
|
195
|
+
return { target_type: 'schedule', target_id: event.targetId };
|
|
149
196
|
}
|
|
150
|
-
|
|
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 };
|
|
203
|
+
}
|
|
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 };
|
|
151
241
|
}
|
|
152
242
|
|
|
153
243
|
async function fetchAllChats(
|
|
@@ -174,8 +264,6 @@ async function fetchAllChats(
|
|
|
174
264
|
|
|
175
265
|
export class ParallAgentGateway {
|
|
176
266
|
private readonly chatInfoMap = new Map<string, ChatInfo>();
|
|
177
|
-
private readonly activeDispatches = new Map<string, ActiveDispatch>();
|
|
178
|
-
private readonly injectedTypingCounts = new Map<string, number>();
|
|
179
267
|
private readonly dispatchedTasks = new Set<string>();
|
|
180
268
|
private readonly dispatchedMessages = new Set<string>();
|
|
181
269
|
private readonly forkStates = new Map<string, ActiveForkState>();
|
|
@@ -186,11 +274,11 @@ export class ParallAgentGateway {
|
|
|
186
274
|
mainBuffer: [],
|
|
187
275
|
};
|
|
188
276
|
|
|
189
|
-
private sessionId =
|
|
277
|
+
private sessionId = '';
|
|
190
278
|
private activeSessionId: string | undefined;
|
|
191
279
|
private readonly sessionBindings = new Map<string, AgentSessionBinding>();
|
|
192
280
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
193
|
-
|
|
281
|
+
|
|
194
282
|
private lastHeartbeatAt = Date.now();
|
|
195
283
|
private draining = false;
|
|
196
284
|
|
|
@@ -204,15 +292,22 @@ export class ParallAgentGateway {
|
|
|
204
292
|
private pendingRestartNotification: string | null = null;
|
|
205
293
|
|
|
206
294
|
private readonly DISPATCHED_MESSAGES_CAP = 5000;
|
|
207
|
-
private readonly COLD_START_WINDOW_MS: number;
|
|
208
295
|
// SHUTDOWN_DEADLINE_MS is read by waitForDrain via the configured value
|
|
209
296
|
// below — kept as instance state so per-runtime configs can override it
|
|
210
297
|
// (see parseShutdownDeadlineMs and runtime entrypoints).
|
|
211
298
|
private readonly SHUTDOWN_DEADLINE_MS: number;
|
|
299
|
+
private readonly FORK_DEADLINE_MS: number;
|
|
300
|
+
private readonly DISPATCH_DEADLINE_MS: number;
|
|
212
301
|
|
|
213
302
|
constructor(private readonly opts: ParallGatewayOptions) {
|
|
214
|
-
this.COLD_START_WINDOW_MS = opts.coldStartWindowMs ?? 5 * 60_000;
|
|
215
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
|
+
}
|
|
216
311
|
}
|
|
217
312
|
|
|
218
313
|
async run(abortSignal: AbortSignal): Promise<void> {
|
|
@@ -222,39 +317,39 @@ export class ParallAgentGateway {
|
|
|
222
317
|
log?.info(`connection state → ${state}`);
|
|
223
318
|
});
|
|
224
319
|
|
|
225
|
-
ws.on(
|
|
320
|
+
ws.on('hello', async (data: HelloData) => {
|
|
226
321
|
await this.handleHello(data);
|
|
227
322
|
});
|
|
228
323
|
|
|
229
|
-
ws.on(
|
|
324
|
+
ws.on('chat.update', (data: ChatUpdateData) => {
|
|
230
325
|
const changes = data.changes as Record<string, unknown> | undefined;
|
|
231
326
|
if (!changes) return;
|
|
232
327
|
const existing = this.chatInfoMap.get(data.chat_id);
|
|
233
328
|
if (existing) {
|
|
234
329
|
this.chatInfoMap.set(data.chat_id, {
|
|
235
330
|
...existing,
|
|
236
|
-
...(typeof changes.type ===
|
|
237
|
-
...(typeof changes.name ===
|
|
238
|
-
...(typeof changes.agent_routing_mode ===
|
|
239
|
-
? { 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'] }
|
|
240
335
|
: {}),
|
|
241
336
|
});
|
|
242
|
-
} else if (typeof changes.type ===
|
|
337
|
+
} else if (typeof changes.type === 'string') {
|
|
243
338
|
this.chatInfoMap.set(data.chat_id, {
|
|
244
|
-
type: changes.type as Chat[
|
|
245
|
-
name: typeof changes.name ===
|
|
246
|
-
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'
|
|
247
342
|
? changes.agent_routing_mode
|
|
248
|
-
:
|
|
343
|
+
: 'passive') as Chat['agent_routing_mode'],
|
|
249
344
|
});
|
|
250
345
|
}
|
|
251
346
|
});
|
|
252
347
|
|
|
253
|
-
ws.on(
|
|
348
|
+
ws.on('message.new', async (data: MessageNewData) => {
|
|
254
349
|
await this.handleMessage(data);
|
|
255
350
|
});
|
|
256
351
|
|
|
257
|
-
ws.on(
|
|
352
|
+
ws.on('agent_config.update', async (data: AgentConfigUpdateData) => {
|
|
258
353
|
this.opts.log?.info(`config update notification (version=${data.version})`);
|
|
259
354
|
try {
|
|
260
355
|
await this.opts.onConfigUpdate?.(data);
|
|
@@ -263,13 +358,12 @@ export class ParallAgentGateway {
|
|
|
263
358
|
}
|
|
264
359
|
});
|
|
265
360
|
|
|
266
|
-
ws.on(
|
|
267
|
-
const prevId = data.previous_session_id ??
|
|
361
|
+
ws.on('agent.new_session', async (data: AgentNewSessionData) => {
|
|
362
|
+
const prevId = data.previous_session_id ?? '';
|
|
268
363
|
this.opts.log?.info(`new session signal received (previous=${prevId})`);
|
|
269
364
|
this.sessionBindings.clear();
|
|
270
365
|
if (prevId) {
|
|
271
|
-
this.pendingRestartNotification =
|
|
272
|
-
`[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.`;
|
|
273
367
|
}
|
|
274
368
|
try {
|
|
275
369
|
await this.opts.onNewSession?.(prevId);
|
|
@@ -278,47 +372,80 @@ export class ParallAgentGateway {
|
|
|
278
372
|
}
|
|
279
373
|
});
|
|
280
374
|
|
|
281
|
-
ws.on(
|
|
375
|
+
ws.on('recovery.overflow', () => {
|
|
282
376
|
this.opts.log?.warn(`recovery.overflow — triggering full catch-up`);
|
|
283
377
|
this.catchUpFromDispatch().catch((err) =>
|
|
284
|
-
this.opts.log?.warn(`overflow catch-up failed: ${String(err)}`)
|
|
378
|
+
this.opts.log?.warn(`overflow catch-up failed: ${String(err)}`),
|
|
379
|
+
);
|
|
285
380
|
});
|
|
286
381
|
|
|
287
|
-
ws.on(
|
|
382
|
+
ws.on('task.assigned', async (data: TaskAssignedData) => {
|
|
288
383
|
if (data.assignee_id !== this.opts.agentUserId) return;
|
|
289
|
-
if (data.status !==
|
|
384
|
+
if (data.status !== 'todo' && data.status !== 'in_progress') return;
|
|
290
385
|
try {
|
|
291
386
|
const dispatched = await this.handleTaskAssignment(data, data.id);
|
|
292
387
|
if (dispatched) {
|
|
293
|
-
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(() => {});
|
|
294
394
|
}
|
|
295
395
|
} catch (err) {
|
|
296
396
|
this.opts.log?.error(`task dispatch failed for ${data.id}: ${String(err)}`);
|
|
297
397
|
}
|
|
298
398
|
});
|
|
299
399
|
|
|
300
|
-
ws.on(
|
|
301
|
-
if (data.event_type ===
|
|
400
|
+
ws.on('dispatch.new', async (data: DispatchNewData) => {
|
|
401
|
+
if (data.event_type === 'task_comment') {
|
|
302
402
|
if (!data.source_id || !data.task_id) return;
|
|
303
403
|
try {
|
|
304
|
-
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
|
+
);
|
|
410
|
+
if (dispatched) {
|
|
411
|
+
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
412
|
+
}
|
|
413
|
+
} catch (err) {
|
|
414
|
+
this.opts.log?.error(
|
|
415
|
+
`task comment dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
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
|
+
);
|
|
305
426
|
if (dispatched) {
|
|
306
427
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
307
428
|
}
|
|
308
429
|
} catch (err) {
|
|
309
|
-
this.opts.log?.error(
|
|
430
|
+
this.opts.log?.error(
|
|
431
|
+
`wiki comment dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
432
|
+
);
|
|
310
433
|
}
|
|
311
|
-
} else if (data.event_type ===
|
|
434
|
+
} else if (data.event_type === 'task_update') {
|
|
312
435
|
if (!data.task_id) return;
|
|
313
436
|
try {
|
|
314
|
-
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
|
+
);
|
|
315
442
|
if (dispatched) {
|
|
316
443
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
317
444
|
}
|
|
318
445
|
} catch (err) {
|
|
319
446
|
this.opts.log?.error(`task update dispatch failed for ${data.task_id}: ${String(err)}`);
|
|
320
447
|
}
|
|
321
|
-
} else if (data.event_type ===
|
|
448
|
+
} else if (data.event_type === 'schedule.fire') {
|
|
322
449
|
if (!data.source_id) return;
|
|
323
450
|
try {
|
|
324
451
|
const dispatched = await this.fetchAndHandleScheduleFire(data.source_id, data.actor_id);
|
|
@@ -326,19 +453,27 @@ export class ParallAgentGateway {
|
|
|
326
453
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
327
454
|
}
|
|
328
455
|
} catch (err) {
|
|
329
|
-
this.opts.log?.error(
|
|
456
|
+
this.opts.log?.error(
|
|
457
|
+
`schedule fire dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
458
|
+
);
|
|
330
459
|
}
|
|
331
|
-
} else if (data.event_type ===
|
|
460
|
+
} else if (data.event_type === 'approval_decided') {
|
|
332
461
|
if (!data.source_id) return;
|
|
333
462
|
try {
|
|
334
|
-
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
|
+
);
|
|
335
468
|
if (dispatched) {
|
|
336
469
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
337
470
|
}
|
|
338
471
|
} catch (err) {
|
|
339
|
-
this.opts.log?.error(
|
|
472
|
+
this.opts.log?.error(
|
|
473
|
+
`approval decided dispatch failed for ${data.source_id}: ${String(err)}`,
|
|
474
|
+
);
|
|
340
475
|
}
|
|
341
|
-
} else if (data.event_type !==
|
|
476
|
+
} else if (data.event_type !== 'message' && data.event_type !== 'task_assign') {
|
|
342
477
|
// Truly unknown event_type — log so a newly-added dispatch type
|
|
343
478
|
// not yet wired here surfaces during runtime testing. "message"
|
|
344
479
|
// and "task_assign" are deliberately excluded: dispatch.new
|
|
@@ -346,15 +481,17 @@ export class ParallAgentGateway {
|
|
|
346
481
|
// (message.new, task.assigned) above and would otherwise spam
|
|
347
482
|
// info-level logs for every inbound chat message / task
|
|
348
483
|
// assignment on a busy agent.
|
|
349
|
-
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
|
+
);
|
|
350
487
|
}
|
|
351
488
|
});
|
|
352
489
|
|
|
353
|
-
this.opts.log?.info(`connecting to ${this.opts.connectionLabel ??
|
|
490
|
+
this.opts.log?.info(`connecting to ${this.opts.connectionLabel ?? 'Parall WS'}...`);
|
|
354
491
|
await ws.connect();
|
|
355
492
|
|
|
356
493
|
return new Promise<void>((resolve) => {
|
|
357
|
-
abortSignal.addEventListener(
|
|
494
|
+
abortSignal.addEventListener('abort', async () => {
|
|
358
495
|
await this.shutdown();
|
|
359
496
|
resolve();
|
|
360
497
|
});
|
|
@@ -374,65 +511,13 @@ export class ParallAgentGateway {
|
|
|
374
511
|
return true;
|
|
375
512
|
}
|
|
376
513
|
|
|
377
|
-
private
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
if (this.opts.ws.state === "connected") this.opts.ws.sendTyping(chatId, "start");
|
|
385
|
-
const typingRefresh = setInterval(() => {
|
|
386
|
-
if (this.opts.ws.state === "connected") this.opts.ws.sendTyping(chatId, "start");
|
|
387
|
-
}, 2000);
|
|
388
|
-
this.activeDispatches.set(chatId, { count: 1, typingTimer: typingRefresh });
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
private stopTyping(chatId: string) {
|
|
392
|
-
const dispatch = this.activeDispatches.get(chatId);
|
|
393
|
-
if (!dispatch) return;
|
|
394
|
-
dispatch.count--;
|
|
395
|
-
if (dispatch.count > 0) return;
|
|
396
|
-
|
|
397
|
-
clearInterval(dispatch.typingTimer);
|
|
398
|
-
this.activeDispatches.delete(chatId);
|
|
399
|
-
if (this.opts.ws.state === "connected") this.opts.ws.sendTyping(chatId, "stop");
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
private shouldShowTyping(event: ParallEvent): boolean {
|
|
403
|
-
return event.type === "message" && event.targetId.startsWith("cht_") && !event.noReply;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
private startInjectedTyping(event: ParallEvent) {
|
|
407
|
-
if (!this.shouldShowTyping(event)) return;
|
|
408
|
-
this.startTyping(event.targetId);
|
|
409
|
-
this.injectedTypingCounts.set(event.targetId, (this.injectedTypingCounts.get(event.targetId) ?? 0) + 1);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
private takeInjectedTypingCount(chatId: string): number {
|
|
413
|
-
const count = this.injectedTypingCounts.get(chatId) ?? 0;
|
|
414
|
-
this.injectedTypingCounts.delete(chatId);
|
|
415
|
-
return count;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
private async runDispatchWithTyping(
|
|
419
|
-
event: ParallEvent,
|
|
420
|
-
sessionKey: string,
|
|
421
|
-
bodyForAgent: string,
|
|
422
|
-
earlierEvents: ParallEvent[] = [],
|
|
423
|
-
captureText?: string[],
|
|
424
|
-
opts: { suppressStart?: boolean; injectedTypingCount?: number } = {},
|
|
425
|
-
): Promise<boolean> {
|
|
426
|
-
const showTyping = !opts.suppressStart && (this.shouldShowTyping(event) || earlierEvents.some(e => this.shouldShowTyping(e)));
|
|
427
|
-
if (showTyping) this.startTyping(event.targetId);
|
|
428
|
-
try {
|
|
429
|
-
return await this.runDispatch(event, sessionKey, bodyForAgent, earlierEvents, captureText);
|
|
430
|
-
} finally {
|
|
431
|
-
if (showTyping) this.stopTyping(event.targetId);
|
|
432
|
-
for (let i = 0; i < (opts.injectedTypingCount ?? 0); i++) {
|
|
433
|
-
this.stopTyping(event.targetId);
|
|
434
|
-
}
|
|
435
|
-
}
|
|
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
|
+
});
|
|
436
521
|
}
|
|
437
522
|
|
|
438
523
|
private buildDispatchContext(event: ParallEvent, sessionKey: string): DispatchContext {
|
|
@@ -446,7 +531,7 @@ export class ParallAgentGateway {
|
|
|
446
531
|
runtimeType: this.opts.runtimeType,
|
|
447
532
|
runtimeKey: this.opts.runtimeKey,
|
|
448
533
|
sessionId: binding?.agentSessionId,
|
|
449
|
-
chatId:
|
|
534
|
+
chatId: event.type === 'message' || event.type === 'approval' ? event.targetId : undefined,
|
|
450
535
|
triggerMessageId: event.messageId,
|
|
451
536
|
noReply: event.noReply ?? false,
|
|
452
537
|
contextFilePath: this.opts.contextFilePathForSession?.(sessionKey),
|
|
@@ -456,81 +541,127 @@ export class ParallAgentGateway {
|
|
|
456
541
|
};
|
|
457
542
|
}
|
|
458
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
|
+
|
|
459
552
|
private async createInputStep(sessionId: string, event: ParallEvent) {
|
|
460
553
|
const target = resolveStepTarget(event);
|
|
461
554
|
try {
|
|
462
|
-
await this.opts.client.createAgentStep(
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
+
},
|
|
483
593
|
},
|
|
484
|
-
|
|
594
|
+
);
|
|
485
595
|
} catch (err) {
|
|
596
|
+
if (this.isSessionNotLiveError(err)) throw err;
|
|
486
597
|
this.opts.log?.warn(`failed to create input step: ${String(err)}`);
|
|
487
598
|
}
|
|
488
599
|
}
|
|
489
600
|
|
|
490
|
-
private async createRuntimeStep(
|
|
491
|
-
|
|
601
|
+
private async createRuntimeStep(
|
|
602
|
+
sessionId: string,
|
|
603
|
+
event: ParallEvent,
|
|
604
|
+
runtimeEvent: RuntimeEvent,
|
|
605
|
+
stepIdFilePath?: string,
|
|
606
|
+
contextFilePath?: string,
|
|
607
|
+
) {
|
|
492
608
|
const target = resolveStepTarget(event);
|
|
493
609
|
try {
|
|
494
610
|
switch (runtimeEvent.type) {
|
|
495
|
-
case
|
|
496
|
-
await this.opts.client.createAgentStep(
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
+
);
|
|
503
624
|
break;
|
|
504
625
|
|
|
505
|
-
case
|
|
506
|
-
await this.opts.client.createAgentStep(
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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,
|
|
513
641
|
},
|
|
514
|
-
|
|
515
|
-
group_key: runtimeEvent.groupKey,
|
|
516
|
-
});
|
|
642
|
+
);
|
|
517
643
|
break;
|
|
518
644
|
|
|
519
|
-
case
|
|
520
|
-
const step = await this.opts.client.createAgentStep(
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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,
|
|
530
663
|
},
|
|
531
|
-
|
|
532
|
-
runtime_key: runtimeEvent.callId,
|
|
533
|
-
});
|
|
664
|
+
);
|
|
534
665
|
if (contextFilePath) {
|
|
535
666
|
this.updateContextFileStepId(contextFilePath, step.id);
|
|
536
667
|
} else if (stepIdFilePath) {
|
|
@@ -539,21 +670,26 @@ export class ParallAgentGateway {
|
|
|
539
670
|
break;
|
|
540
671
|
}
|
|
541
672
|
|
|
542
|
-
case
|
|
543
|
-
await this.opts.client.createAgentStep(
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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,
|
|
554
691
|
},
|
|
555
|
-
|
|
556
|
-
});
|
|
692
|
+
);
|
|
557
693
|
if (contextFilePath) {
|
|
558
694
|
this.updateContextFileStepId(contextFilePath, null);
|
|
559
695
|
} else if (stepIdFilePath) {
|
|
@@ -561,17 +697,23 @@ export class ParallAgentGateway {
|
|
|
561
697
|
}
|
|
562
698
|
break;
|
|
563
699
|
|
|
564
|
-
case
|
|
565
|
-
await this.opts.client.createAgentStep(
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
+
);
|
|
572
713
|
break;
|
|
573
714
|
}
|
|
574
715
|
} catch (err) {
|
|
716
|
+
if (this.isSessionNotLiveError(err)) throw err;
|
|
575
717
|
this.opts.log?.warn(`failed to create ${runtimeEvent.type} step: ${String(err)}`);
|
|
576
718
|
}
|
|
577
719
|
}
|
|
@@ -579,7 +721,7 @@ export class ParallAgentGateway {
|
|
|
579
721
|
private writeContextFile(filePath: string, ctx: Record<string, unknown>) {
|
|
580
722
|
try {
|
|
581
723
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
582
|
-
fs.writeFileSync(filePath, JSON.stringify(ctx),
|
|
724
|
+
fs.writeFileSync(filePath, JSON.stringify(ctx), 'utf8');
|
|
583
725
|
} catch (err) {
|
|
584
726
|
this.opts.log?.warn(`failed to write context file ${filePath}: ${String(err)}`);
|
|
585
727
|
}
|
|
@@ -587,10 +729,10 @@ export class ParallAgentGateway {
|
|
|
587
729
|
|
|
588
730
|
private updateContextFileStepId(filePath: string, stepId: string | null) {
|
|
589
731
|
try {
|
|
590
|
-
const raw = fs.readFileSync(filePath,
|
|
732
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
591
733
|
const ctx = JSON.parse(raw);
|
|
592
734
|
ctx.step_id = stepId;
|
|
593
|
-
fs.writeFileSync(filePath, JSON.stringify(ctx),
|
|
735
|
+
fs.writeFileSync(filePath, JSON.stringify(ctx), 'utf8');
|
|
594
736
|
} catch (err) {
|
|
595
737
|
this.opts.log?.warn(`failed to update context file step_id ${filePath}: ${String(err)}`);
|
|
596
738
|
}
|
|
@@ -598,10 +740,10 @@ export class ParallAgentGateway {
|
|
|
598
740
|
|
|
599
741
|
private updateContextFileSessionId(filePath: string, sessionId: string) {
|
|
600
742
|
try {
|
|
601
|
-
const raw = fs.readFileSync(filePath,
|
|
743
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
602
744
|
const ctx = JSON.parse(raw);
|
|
603
745
|
ctx.session_id = sessionId;
|
|
604
|
-
fs.writeFileSync(filePath, JSON.stringify(ctx),
|
|
746
|
+
fs.writeFileSync(filePath, JSON.stringify(ctx), 'utf8');
|
|
605
747
|
} catch (err) {
|
|
606
748
|
this.opts.log?.warn(`failed to update context file session_id ${filePath}: ${String(err)}`);
|
|
607
749
|
}
|
|
@@ -611,7 +753,7 @@ export class ParallAgentGateway {
|
|
|
611
753
|
private writeStepIdFile(filePath: string, stepId: string) {
|
|
612
754
|
try {
|
|
613
755
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
614
|
-
fs.writeFileSync(filePath, stepId,
|
|
756
|
+
fs.writeFileSync(filePath, stepId, 'utf8');
|
|
615
757
|
} catch (err) {
|
|
616
758
|
this.opts.log?.warn(`failed to write step id file ${filePath}: ${String(err)}`);
|
|
617
759
|
}
|
|
@@ -620,7 +762,7 @@ export class ParallAgentGateway {
|
|
|
620
762
|
/** @deprecated Use writeContextFile / updateContextFileStepId. */
|
|
621
763
|
private clearStepIdFile(filePath: string) {
|
|
622
764
|
try {
|
|
623
|
-
fs.writeFileSync(filePath,
|
|
765
|
+
fs.writeFileSync(filePath, '', 'utf8');
|
|
624
766
|
} catch {
|
|
625
767
|
// Best-effort cleanup.
|
|
626
768
|
}
|
|
@@ -634,7 +776,7 @@ export class ParallAgentGateway {
|
|
|
634
776
|
|
|
635
777
|
private async bindRuntimeSession(
|
|
636
778
|
sessionKey: string,
|
|
637
|
-
runtimeEvent: Extract<RuntimeEvent, { type:
|
|
779
|
+
runtimeEvent: Extract<RuntimeEvent, { type: 'runtime_session' }>,
|
|
638
780
|
contextFilePath?: string,
|
|
639
781
|
): Promise<AgentSessionBinding> {
|
|
640
782
|
const runtimeLaneKey = runtimeEvent.runtimeLaneKey || sessionKey;
|
|
@@ -647,28 +789,39 @@ export class ParallAgentGateway {
|
|
|
647
789
|
return existing;
|
|
648
790
|
}
|
|
649
791
|
|
|
650
|
-
const parentSessionId =
|
|
651
|
-
|
|
652
|
-
|
|
792
|
+
const parentSessionId =
|
|
793
|
+
sessionKey === this.opts.runtimeKey
|
|
794
|
+
? undefined
|
|
795
|
+
: this.sessionBindings.get(this.opts.runtimeKey)?.agentSessionId;
|
|
653
796
|
const runtimeRef = {
|
|
654
797
|
...(this.opts.runtimeRef ?? {}),
|
|
655
798
|
...(runtimeEvent.runtimeRef ?? {}),
|
|
656
799
|
};
|
|
657
|
-
const session: AgentSessionDB = await this.opts.client.createAgentSession(
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
+
);
|
|
665
812
|
if (!LIVE_SESSION_STATUSES.has(session.status)) {
|
|
666
813
|
this.opts.log?.warn?.(
|
|
667
814
|
`createAgentSession returned terminal session ${session.id} (${session.status}), resetting runtime for ${sessionKey}`,
|
|
668
815
|
);
|
|
669
816
|
this.sessionBindings.delete(sessionKey);
|
|
670
|
-
try {
|
|
671
|
-
|
|
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
|
+
);
|
|
672
825
|
throw new Error(`Agent session ${session.id} is terminal (${session.status})`);
|
|
673
826
|
}
|
|
674
827
|
|
|
@@ -712,125 +865,274 @@ export class ParallAgentGateway {
|
|
|
712
865
|
}
|
|
713
866
|
|
|
714
867
|
if (this.pendingRestartNotification) {
|
|
715
|
-
bodyForAgent = this.pendingRestartNotification +
|
|
868
|
+
bodyForAgent = this.pendingRestartNotification + '\n\n---\n\n' + bodyForAgent;
|
|
716
869
|
this.pendingRestartNotification = null;
|
|
717
870
|
}
|
|
718
871
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
setDispatchNoReply(sessionKey, event.noReply ?? false);
|
|
872
|
+
resetDispatchMetrics(sessionKey);
|
|
873
|
+
return runWithSessionKey(sessionKey, async () => {
|
|
874
|
+
let dispatchSpan: ReturnType<typeof startDispatchSpan> = null;
|
|
723
875
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
876
|
+
setSessionChatId(sessionKey, event.targetId);
|
|
877
|
+
setSessionMessageId(sessionKey, event.messageId);
|
|
878
|
+
setDispatchMessageId(sessionKey, event.messageId);
|
|
879
|
+
setDispatchNoReply(sessionKey, event.noReply ?? false);
|
|
727
880
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
chat_id: dispatchContext.chatId ?? null,
|
|
732
|
-
trigger_message_id: dispatchContext.triggerMessageId ?? null,
|
|
733
|
-
no_reply: dispatchContext.noReply,
|
|
734
|
-
step_id: null,
|
|
735
|
-
});
|
|
736
|
-
}
|
|
881
|
+
const dispatchContext = this.buildDispatchContext(event, sessionKey);
|
|
882
|
+
const contextFilePath = dispatchContext.contextFilePath;
|
|
883
|
+
const stepIdFilePath = dispatchContext.stepIdFilePath;
|
|
737
884
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
+
}
|
|
755
959
|
if (!inputStepsCreated) {
|
|
756
|
-
// Persist input steps for "earlier events" (batched events that arrived
|
|
757
|
-
// while a dispatch was in flight) inside the in-flight window so a
|
|
758
|
-
// shutdown short-circuit BEFORE this point cannot leave orphan input
|
|
759
|
-
// steps that the replacement pod would duplicate on replay.
|
|
760
960
|
if (earlierEvents.length > 0) {
|
|
761
961
|
await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
|
|
762
962
|
}
|
|
763
963
|
await this.createInputStep(binding.agentSessionId, event);
|
|
764
964
|
inputStepsCreated = true;
|
|
765
965
|
}
|
|
766
|
-
|
|
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
|
+
);
|
|
767
992
|
}
|
|
768
|
-
|
|
769
993
|
if (!binding) {
|
|
770
|
-
|
|
771
|
-
throw new Error(`runtime emitted ${runtimeEvent.type} before runtime_session${detail}`);
|
|
994
|
+
binding = this.sessionBindings.get(sessionKey);
|
|
772
995
|
}
|
|
773
|
-
if (!
|
|
774
|
-
|
|
775
|
-
this.opts.client.updateAgentSession(
|
|
776
|
-
this.opts.config.org_id, this.opts.agentUserId, binding.agentSessionId,
|
|
777
|
-
{ status: "active", trigger_message_id: event.messageId },
|
|
778
|
-
).catch((err) => this.opts.log?.warn?.(`failed to set session active: ${err}`));
|
|
996
|
+
if (!binding) {
|
|
997
|
+
throw new Error('runtime completed without runtime_session');
|
|
779
998
|
}
|
|
780
999
|
if (!inputStepsCreated) {
|
|
781
1000
|
if (earlierEvents.length > 0) {
|
|
782
1001
|
await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
|
|
783
1002
|
}
|
|
784
1003
|
await this.createInputStep(binding.agentSessionId, event);
|
|
785
|
-
inputStepsCreated = true;
|
|
786
1004
|
}
|
|
787
|
-
|
|
788
|
-
|
|
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
|
+
}
|
|
789
1023
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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();
|
|
801
1087
|
}
|
|
802
|
-
await this.createInputStep(binding.agentSessionId, event);
|
|
803
|
-
}
|
|
804
|
-
} catch (err) {
|
|
805
|
-
if (binding) {
|
|
806
|
-
await this.createRuntimeStep(binding.agentSessionId, event, {
|
|
807
|
-
type: "error",
|
|
808
|
-
message: `Dispatch failed: ${String(err)}`,
|
|
809
|
-
}, stepIdFilePath, contextFilePath);
|
|
810
|
-
}
|
|
811
|
-
throw err;
|
|
812
|
-
} finally {
|
|
813
|
-
if (triggerMessageSet && binding) {
|
|
814
|
-
this.opts.client.updateAgentSession(
|
|
815
|
-
this.opts.config.org_id, this.opts.agentUserId, binding.agentSessionId,
|
|
816
|
-
{ status: "idle" },
|
|
817
|
-
).catch((err) => this.opts.log?.warn?.(`failed to set session idle: ${err}`));
|
|
818
|
-
}
|
|
819
|
-
clearSessionMessageId(sessionKey);
|
|
820
|
-
clearDispatchMessageId(sessionKey);
|
|
821
|
-
clearDispatchNoReply(sessionKey);
|
|
822
|
-
if (contextFilePath) {
|
|
823
|
-
this.updateContextFileStepId(contextFilePath, null);
|
|
824
|
-
} else if (stepIdFilePath) {
|
|
825
|
-
this.clearStepIdFile(stepIdFilePath);
|
|
826
|
-
}
|
|
827
|
-
this.inFlightDispatches--;
|
|
828
|
-
if (this.inFlightDispatches === 0 && this.drainResolvers.length > 0) {
|
|
829
|
-
const resolvers = this.drainResolvers.splice(0);
|
|
830
|
-
for (const resolve of resolvers) resolve();
|
|
831
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
|
+
});
|
|
832
1135
|
}
|
|
833
|
-
return true;
|
|
834
1136
|
}
|
|
835
1137
|
|
|
836
1138
|
private async runForkDrainLoop(fork: ActiveForkState) {
|
|
@@ -843,7 +1145,7 @@ export class ParallAgentGateway {
|
|
|
843
1145
|
// shuttingDown gate and inFlightDispatches counter, so a shutdown
|
|
844
1146
|
// landing mid-batch cannot leave orphan steps that replay would
|
|
845
1147
|
// duplicate.
|
|
846
|
-
if (this.shuttingDown) {
|
|
1148
|
+
if (this.shuttingDown || fork.deadlineExceeded) {
|
|
847
1149
|
for (const item of fork.queue.splice(0)) item.resolve(false);
|
|
848
1150
|
break;
|
|
849
1151
|
}
|
|
@@ -853,7 +1155,13 @@ export class ParallAgentGateway {
|
|
|
853
1155
|
const earlier = events.slice(0, -1);
|
|
854
1156
|
try {
|
|
855
1157
|
const batchText: string[] = [];
|
|
856
|
-
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
|
+
);
|
|
857
1165
|
if (!dispatched) {
|
|
858
1166
|
// Shutdown short-circuit — resolve un-acked so the server requeues
|
|
859
1167
|
// for the replacement pod and stop draining further items.
|
|
@@ -863,6 +1171,10 @@ export class ParallAgentGateway {
|
|
|
863
1171
|
break;
|
|
864
1172
|
}
|
|
865
1173
|
if (batchText.length > 0) lastCapturedText = batchText;
|
|
1174
|
+
if (fork.deadlineExceeded) {
|
|
1175
|
+
for (const item of items) item.resolve(false);
|
|
1176
|
+
break;
|
|
1177
|
+
}
|
|
866
1178
|
fork.processedEvents.push(...events);
|
|
867
1179
|
for (const item of items) {
|
|
868
1180
|
item.resolve(true);
|
|
@@ -880,9 +1192,13 @@ export class ParallAgentGateway {
|
|
|
880
1192
|
remaining.resolve(false);
|
|
881
1193
|
}
|
|
882
1194
|
} finally {
|
|
1195
|
+
if (fork.deadlineTimer) {
|
|
1196
|
+
clearTimeout(fork.deadlineTimer);
|
|
1197
|
+
fork.deadlineTimer = null;
|
|
1198
|
+
}
|
|
883
1199
|
if (fork.processedEvents.length > 0) {
|
|
884
1200
|
const first = fork.processedEvents[0];
|
|
885
|
-
const agentSummary = lastCapturedText.join(
|
|
1201
|
+
const agentSummary = lastCapturedText.join('').trim() || undefined;
|
|
886
1202
|
let historyPath: string | undefined;
|
|
887
1203
|
try {
|
|
888
1204
|
historyPath = this.opts.dispatchAdapter.getSessionHistoryPath?.(fork.fork.sessionKey);
|
|
@@ -894,9 +1210,10 @@ export class ParallAgentGateway {
|
|
|
894
1210
|
sourceEvent: {
|
|
895
1211
|
type: first.type,
|
|
896
1212
|
targetId: fork.targetId,
|
|
897
|
-
summary:
|
|
898
|
-
|
|
899
|
-
|
|
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}`,
|
|
900
1217
|
},
|
|
901
1218
|
eventBodies: fork.processedEvents.map((e) => buildEventBodyForForkResult(e)),
|
|
902
1219
|
actions: [],
|
|
@@ -904,8 +1221,12 @@ export class ParallAgentGateway {
|
|
|
904
1221
|
historyPath,
|
|
905
1222
|
});
|
|
906
1223
|
}
|
|
907
|
-
this.forkStates.
|
|
908
|
-
|
|
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
|
+
}
|
|
909
1230
|
const forkBinding = this.sessionBindings.get(fork.fork.sessionKey);
|
|
910
1231
|
if (this.opts.dispatchAdapter.cleanupFork) {
|
|
911
1232
|
const cleanupOpts: CleanupForkOpts = {
|
|
@@ -924,13 +1245,21 @@ export class ParallAgentGateway {
|
|
|
924
1245
|
log: this.opts.log,
|
|
925
1246
|
},
|
|
926
1247
|
};
|
|
927
|
-
|
|
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
|
+
}
|
|
928
1253
|
}
|
|
929
1254
|
if (forkBinding) {
|
|
930
|
-
this.opts.client
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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(() => {});
|
|
934
1263
|
this.sessionBindings.delete(fork.fork.sessionKey);
|
|
935
1264
|
}
|
|
936
1265
|
}
|
|
@@ -960,20 +1289,31 @@ export class ParallAgentGateway {
|
|
|
960
1289
|
|
|
961
1290
|
const event = events[events.length - 1];
|
|
962
1291
|
const earlier = events.slice(0, -1);
|
|
963
|
-
const hasPendingInjections =
|
|
964
|
-
|
|
965
|
-
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);
|
|
966
1297
|
const forkPrefix = buildForkResultPrefix(pendingFork);
|
|
967
1298
|
this.dispatchState.mainCurrentTargetId = event.targetId;
|
|
968
|
-
this.dispatchState.mainPreDispatchBranchPoint =
|
|
969
|
-
this.opts.
|
|
970
|
-
|
|
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(
|
|
971
1313
|
event,
|
|
972
1314
|
this.opts.runtimeKey,
|
|
973
1315
|
forkPrefix + buildEventBody(event),
|
|
974
1316
|
earlier,
|
|
975
|
-
undefined,
|
|
976
|
-
{ suppressStart: injectedTypingCount > 0, injectedTypingCount },
|
|
977
1317
|
);
|
|
978
1318
|
if (!dispatched) {
|
|
979
1319
|
// Shutdown: skip the ack so the server redelivers these buffered
|
|
@@ -984,12 +1324,16 @@ export class ParallAgentGateway {
|
|
|
984
1324
|
break;
|
|
985
1325
|
}
|
|
986
1326
|
for (const bufferedEvent of events) {
|
|
987
|
-
const sourceType =
|
|
1327
|
+
const sourceType =
|
|
1328
|
+
bufferedEvent.ackSourceType ??
|
|
1329
|
+
(bufferedEvent.type === 'task' ? 'task_activity' : 'message');
|
|
988
1330
|
const sourceId = bufferedEvent.ackSourceId ?? bufferedEvent.messageId;
|
|
989
|
-
this.opts.client
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1331
|
+
this.opts.client
|
|
1332
|
+
.ackDispatch(this.opts.config.org_id, {
|
|
1333
|
+
source_type: sourceType,
|
|
1334
|
+
source_id: sourceId,
|
|
1335
|
+
})
|
|
1336
|
+
.catch(() => {});
|
|
993
1337
|
}
|
|
994
1338
|
}
|
|
995
1339
|
} finally {
|
|
@@ -997,6 +1341,18 @@ export class ParallAgentGateway {
|
|
|
997
1341
|
this.dispatchState.mainDispatching = false;
|
|
998
1342
|
this.dispatchState.mainCurrentTargetId = undefined;
|
|
999
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
|
+
}
|
|
1000
1356
|
}
|
|
1001
1357
|
}
|
|
1002
1358
|
|
|
@@ -1004,7 +1360,7 @@ export class ParallAgentGateway {
|
|
|
1004
1360
|
const disposition = routeTrigger(event, this.dispatchState);
|
|
1005
1361
|
|
|
1006
1362
|
switch (disposition.action) {
|
|
1007
|
-
case
|
|
1363
|
+
case 'main': {
|
|
1008
1364
|
const pendingFork = this.dispatchState.pendingForkResults.splice(0);
|
|
1009
1365
|
const forkPrefix = buildForkResultPrefix(pendingFork);
|
|
1010
1366
|
this.dispatchState.mainDispatching = true;
|
|
@@ -1012,11 +1368,26 @@ export class ParallAgentGateway {
|
|
|
1012
1368
|
// Snapshot the on-disk branch point BEFORE runDispatch starts writing
|
|
1013
1369
|
// to the session file. Fork sessions created while main is in-flight
|
|
1014
1370
|
// use this to branch from the clean pre-dispatch state.
|
|
1015
|
-
this.dispatchState.mainPreDispatchBranchPoint =
|
|
1016
|
-
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
|
+
}
|
|
1017
1384
|
let dispatched = false;
|
|
1018
1385
|
try {
|
|
1019
|
-
dispatched = await this.
|
|
1386
|
+
dispatched = await this.runDispatch(
|
|
1387
|
+
event,
|
|
1388
|
+
this.opts.runtimeKey,
|
|
1389
|
+
forkPrefix + buildEventBody(event),
|
|
1390
|
+
);
|
|
1020
1391
|
if (!dispatched) {
|
|
1021
1392
|
// Shutdown short-circuit — restore the fork results so a future
|
|
1022
1393
|
// pod can replay them, and return false so handleMessage skips ack.
|
|
@@ -1028,7 +1399,7 @@ export class ParallAgentGateway {
|
|
|
1028
1399
|
return dispatched;
|
|
1029
1400
|
}
|
|
1030
1401
|
|
|
1031
|
-
case
|
|
1402
|
+
case 'buffer-main': {
|
|
1032
1403
|
if (this.shuttingDown) {
|
|
1033
1404
|
return false;
|
|
1034
1405
|
}
|
|
@@ -1038,24 +1409,28 @@ export class ParallAgentGateway {
|
|
|
1038
1409
|
this.dispatchState.mainBuffer.push(event);
|
|
1039
1410
|
if (
|
|
1040
1411
|
this.dispatchState.mainCurrentTargetId === event.targetId &&
|
|
1041
|
-
await this.opts.dispatchAdapter.enqueueDuringDispatch?.(
|
|
1412
|
+
(await this.opts.dispatchAdapter.enqueueDuringDispatch?.(
|
|
1413
|
+
this.opts.runtimeKey,
|
|
1414
|
+
buildEventBody(event),
|
|
1415
|
+
))
|
|
1042
1416
|
) {
|
|
1043
|
-
this.
|
|
1044
|
-
this.opts.log?.info(
|
|
1045
|
-
`steer injected for ${event.messageId} (will drain for bookkeeping)`,
|
|
1046
|
-
);
|
|
1417
|
+
this.opts.log?.info(`steer injected for ${event.messageId} (will drain for bookkeeping)`);
|
|
1047
1418
|
}
|
|
1048
1419
|
// If the main dispatch cycle ended while we awaited the steer RPC,
|
|
1049
1420
|
// our event is buffered but no drain is in flight. Re-enter the
|
|
1050
1421
|
// drain to process it. The draining guard prevents re-entry.
|
|
1051
|
-
if (
|
|
1422
|
+
if (
|
|
1423
|
+
!this.dispatchState.mainDispatching &&
|
|
1424
|
+
!this.draining &&
|
|
1425
|
+
this.dispatchState.mainBuffer.length > 0
|
|
1426
|
+
) {
|
|
1052
1427
|
this.dispatchState.mainDispatching = true;
|
|
1053
1428
|
void this.drainMainBuffer();
|
|
1054
1429
|
}
|
|
1055
1430
|
return false;
|
|
1056
1431
|
}
|
|
1057
1432
|
|
|
1058
|
-
case
|
|
1433
|
+
case 'buffer-fork': {
|
|
1059
1434
|
const activeFork = this.forkStates.get(event.targetId);
|
|
1060
1435
|
if (!activeFork) {
|
|
1061
1436
|
this.dispatchState.mainBuffer.push(event);
|
|
@@ -1066,12 +1441,21 @@ export class ParallAgentGateway {
|
|
|
1066
1441
|
});
|
|
1067
1442
|
}
|
|
1068
1443
|
|
|
1069
|
-
case
|
|
1444
|
+
case 'new-fork': {
|
|
1070
1445
|
if (!this.opts.dispatchAdapter.forkSession) {
|
|
1071
1446
|
this.dispatchState.mainBuffer.push(event);
|
|
1072
1447
|
return false;
|
|
1073
1448
|
}
|
|
1074
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
|
+
}
|
|
1075
1459
|
const fork = await this.opts.dispatchAdapter.forkSession({
|
|
1076
1460
|
sessionKey: this.opts.runtimeKey,
|
|
1077
1461
|
context: this.buildDispatchContext(event, this.opts.runtimeKey),
|
|
@@ -1089,10 +1473,16 @@ export class ParallAgentGateway {
|
|
|
1089
1473
|
targetId: event.targetId,
|
|
1090
1474
|
queue: [],
|
|
1091
1475
|
processedEvents: [],
|
|
1476
|
+
deadlineTimer: null,
|
|
1477
|
+
deadlineExceeded: false,
|
|
1092
1478
|
};
|
|
1093
1479
|
this.forkStates.set(event.targetId, activeFork);
|
|
1094
1480
|
this.dispatchState.activeForks.set(event.targetId, fork.sessionKey);
|
|
1095
1481
|
|
|
1482
|
+
activeFork.deadlineTimer = setTimeout(() => {
|
|
1483
|
+
this.abortFork(event.targetId, `deadline exceeded (${this.FORK_DEADLINE_MS}ms)`);
|
|
1484
|
+
}, this.FORK_DEADLINE_MS);
|
|
1485
|
+
|
|
1096
1486
|
const firstEventPromise = new Promise<boolean>((resolve) => {
|
|
1097
1487
|
activeFork.queue.push({ event, resolve });
|
|
1098
1488
|
});
|
|
@@ -1126,25 +1516,26 @@ export class ParallAgentGateway {
|
|
|
1126
1516
|
chatId: string,
|
|
1127
1517
|
message: DispatchableMessage,
|
|
1128
1518
|
): Promise<MessageDispatchDecision> {
|
|
1129
|
-
if (message.message_type !==
|
|
1130
|
-
return { action:
|
|
1519
|
+
if (message.message_type !== 'text') {
|
|
1520
|
+
return { action: 'skip' };
|
|
1131
1521
|
}
|
|
1132
1522
|
|
|
1133
1523
|
const chatInfo = await this.getOrFetchChatInfo(chatId);
|
|
1134
|
-
if (!chatInfo) return { action:
|
|
1524
|
+
if (!chatInfo) return { action: 'retry' };
|
|
1135
1525
|
|
|
1136
1526
|
const content = message.content as TextContent;
|
|
1137
|
-
const body = content.text?.trim() ??
|
|
1527
|
+
const body = content.text?.trim() ?? '';
|
|
1138
1528
|
const hasAttachments = message.attachments?.length;
|
|
1139
1529
|
|
|
1140
|
-
if (!body && !hasAttachments) return { action:
|
|
1530
|
+
if (!body && !hasAttachments) return { action: 'skip' };
|
|
1141
1531
|
|
|
1142
|
-
if (chatInfo.type ===
|
|
1532
|
+
if (chatInfo.type === 'group' && chatInfo.agentRoutingMode !== 'active') {
|
|
1143
1533
|
const mentions = content.mentions ?? [];
|
|
1144
1534
|
const isMentioned = mentions.some(
|
|
1145
|
-
(mention) =>
|
|
1535
|
+
(mention) =>
|
|
1536
|
+
mention.user_id === this.opts.agentUserId || mention.user_id === MENTION_ALL_USER_ID,
|
|
1146
1537
|
);
|
|
1147
|
-
if (!isMentioned) return { action:
|
|
1538
|
+
if (!isMentioned) return { action: 'skip' };
|
|
1148
1539
|
}
|
|
1149
1540
|
|
|
1150
1541
|
const attachments = (message.attachments ?? []).map((a) => ({
|
|
@@ -1154,23 +1545,57 @@ export class ParallAgentGateway {
|
|
|
1154
1545
|
mimeType: a.mime_type,
|
|
1155
1546
|
}));
|
|
1156
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
|
+
|
|
1157
1577
|
return {
|
|
1158
|
-
action:
|
|
1578
|
+
action: 'dispatch',
|
|
1159
1579
|
event: {
|
|
1160
|
-
type:
|
|
1580
|
+
type: 'message',
|
|
1161
1581
|
targetId: chatId,
|
|
1162
1582
|
targetName: chatInfo.name ?? undefined,
|
|
1163
1583
|
targetType: chatInfo.type,
|
|
1164
1584
|
senderId: message.sender_id,
|
|
1165
1585
|
senderName: message.sender?.display_name ?? message.sender_id,
|
|
1166
1586
|
messageId: message.id,
|
|
1167
|
-
body: body ||
|
|
1587
|
+
body: body || '[attachment]',
|
|
1168
1588
|
threadRootId: message.thread_root_id ?? undefined,
|
|
1169
1589
|
noReply: message.hints?.no_reply ?? false,
|
|
1170
1590
|
attachments: attachments.length > 0 ? attachments : undefined,
|
|
1171
1591
|
sentAt: message.created_at,
|
|
1172
|
-
ackSourceType:
|
|
1592
|
+
ackSourceType: 'message',
|
|
1173
1593
|
ackSourceId: message.id,
|
|
1594
|
+
unreadCount,
|
|
1595
|
+
unreadSince,
|
|
1596
|
+
threadReplyCount,
|
|
1597
|
+
threadUnreadCount,
|
|
1598
|
+
threadUnreadSince,
|
|
1174
1599
|
},
|
|
1175
1600
|
};
|
|
1176
1601
|
}
|
|
@@ -1179,11 +1604,11 @@ export class ParallAgentGateway {
|
|
|
1179
1604
|
if (this.shuttingDown) return; // drain window — let server requeue via catch-up
|
|
1180
1605
|
const chatId = data.chat_id;
|
|
1181
1606
|
if (data.sender_id === this.opts.agentUserId) return;
|
|
1182
|
-
if (data.message_type !==
|
|
1607
|
+
if (data.message_type !== 'text') return;
|
|
1183
1608
|
if (!this.tryClaimMessage(data.id)) return;
|
|
1184
1609
|
|
|
1185
1610
|
const decision = await this.buildMessageDispatchDecision(chatId, data);
|
|
1186
|
-
if (decision.action !==
|
|
1611
|
+
if (decision.action !== 'dispatch') {
|
|
1187
1612
|
this.dispatchedMessages.delete(data.id);
|
|
1188
1613
|
return;
|
|
1189
1614
|
}
|
|
@@ -1192,7 +1617,9 @@ export class ParallAgentGateway {
|
|
|
1192
1617
|
try {
|
|
1193
1618
|
const dispatched = await this.handleInboundEvent(event);
|
|
1194
1619
|
if (dispatched) {
|
|
1195
|
-
this.opts.client
|
|
1620
|
+
this.opts.client
|
|
1621
|
+
.ackDispatch(this.opts.config.org_id, { source_type: 'message', source_id: data.id })
|
|
1622
|
+
.catch(() => {});
|
|
1196
1623
|
} else {
|
|
1197
1624
|
this.dispatchedMessages.delete(data.id);
|
|
1198
1625
|
}
|
|
@@ -1216,19 +1643,19 @@ export class ParallAgentGateway {
|
|
|
1216
1643
|
parts.push(`Status: ${task.status}`, `Priority: ${task.priority}`);
|
|
1217
1644
|
if (task.project_id) parts.push(`Project: prll://${task.project_id}`);
|
|
1218
1645
|
if (task.parent_id) parts.push(`Parent: prll://${task.parent_id}`);
|
|
1219
|
-
if (task.description) parts.push(
|
|
1646
|
+
if (task.description) parts.push('', task.description);
|
|
1220
1647
|
|
|
1221
1648
|
const event: ParallEvent = {
|
|
1222
|
-
type:
|
|
1649
|
+
type: 'task',
|
|
1223
1650
|
targetId: task.id,
|
|
1224
1651
|
targetName: task.identifier ?? undefined,
|
|
1225
|
-
targetType:
|
|
1652
|
+
targetType: 'task',
|
|
1226
1653
|
senderId: task.creator_id,
|
|
1227
|
-
senderName:
|
|
1654
|
+
senderName: 'system',
|
|
1228
1655
|
messageId: task.id,
|
|
1229
|
-
body: parts.join(
|
|
1656
|
+
body: parts.join('\n'),
|
|
1230
1657
|
sentAt: task.updated_at ?? task.created_at,
|
|
1231
|
-
ackSourceType:
|
|
1658
|
+
ackSourceType: 'task_activity',
|
|
1232
1659
|
ackSourceId,
|
|
1233
1660
|
};
|
|
1234
1661
|
|
|
@@ -1301,10 +1728,12 @@ export class ParallAgentGateway {
|
|
|
1301
1728
|
let task: Task | null = null;
|
|
1302
1729
|
try {
|
|
1303
1730
|
task = await this.opts.client.getTask(this.opts.config.org_id, taskId);
|
|
1304
|
-
} catch {
|
|
1731
|
+
} catch {
|
|
1732
|
+
/* task context is optional */
|
|
1733
|
+
}
|
|
1305
1734
|
|
|
1306
1735
|
const taskLabel = task ? `${task.identifier ?? task.id} "${task.title}"` : taskId;
|
|
1307
|
-
this.opts.log?.info(`task comment on ${taskLabel} by ${actorId ??
|
|
1736
|
+
this.opts.log?.info(`task comment on ${taskLabel} by ${actorId ?? 'unknown'}`);
|
|
1308
1737
|
|
|
1309
1738
|
const parts: string[] = [];
|
|
1310
1739
|
if (task) {
|
|
@@ -1313,20 +1742,97 @@ export class ParallAgentGateway {
|
|
|
1313
1742
|
} else {
|
|
1314
1743
|
parts.push(`Task: prll://${taskId}`);
|
|
1315
1744
|
}
|
|
1316
|
-
parts.push(
|
|
1317
|
-
|
|
1745
|
+
parts.push(
|
|
1746
|
+
`Comment by: ${comment.author?.display_name ?? actorId ?? 'unknown'} (prll://${comment.author_id})`,
|
|
1747
|
+
);
|
|
1748
|
+
parts.push('', comment.body);
|
|
1318
1749
|
|
|
1319
1750
|
const event: ParallEvent = {
|
|
1320
|
-
type:
|
|
1751
|
+
type: 'task_comment',
|
|
1321
1752
|
targetId: taskId,
|
|
1322
1753
|
targetName: task?.identifier ?? undefined,
|
|
1323
|
-
targetType:
|
|
1754
|
+
targetType: 'task',
|
|
1324
1755
|
senderId: comment.author_id,
|
|
1325
|
-
senderName: comment.author?.display_name ?? actorId ??
|
|
1756
|
+
senderName: comment.author?.display_name ?? actorId ?? 'unknown',
|
|
1326
1757
|
messageId: commentId,
|
|
1327
|
-
body: parts.join(
|
|
1758
|
+
body: parts.join('\n'),
|
|
1328
1759
|
deliveryReason: deliveryReason ?? undefined,
|
|
1329
|
-
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',
|
|
1330
1836
|
ackSourceId: commentId,
|
|
1331
1837
|
};
|
|
1332
1838
|
|
|
@@ -1352,7 +1858,10 @@ export class ParallAgentGateway {
|
|
|
1352
1858
|
* access to already-delivered run snapshots, and the runtime must not crash
|
|
1353
1859
|
* or retry forever in that case.
|
|
1354
1860
|
*/
|
|
1355
|
-
private async fetchAndHandleScheduleFire(
|
|
1861
|
+
private async fetchAndHandleScheduleFire(
|
|
1862
|
+
runId: string,
|
|
1863
|
+
actorId: string | null,
|
|
1864
|
+
): Promise<boolean> {
|
|
1356
1865
|
let run: ScheduleRun | null = null;
|
|
1357
1866
|
try {
|
|
1358
1867
|
run = await this.opts.client.getScheduleRun(this.opts.config.org_id, runId);
|
|
@@ -1367,7 +1876,9 @@ export class ParallAgentGateway {
|
|
|
1367
1876
|
this.opts.log?.warn(`schedule run ${runId} not accessible (404), acking stale dispatch`);
|
|
1368
1877
|
return true;
|
|
1369
1878
|
}
|
|
1370
|
-
this.opts.log?.warn(
|
|
1879
|
+
this.opts.log?.warn(
|
|
1880
|
+
`schedule run fetch failed for ${runId}, leaving pending: ${String(err)}`,
|
|
1881
|
+
);
|
|
1371
1882
|
return false;
|
|
1372
1883
|
}
|
|
1373
1884
|
if (!run) return true;
|
|
@@ -1382,20 +1893,20 @@ export class ParallAgentGateway {
|
|
|
1382
1893
|
this.opts.log?.info(`schedule fired: ${run.id} (schedule ${run.schedule_id})`);
|
|
1383
1894
|
|
|
1384
1895
|
const event: ParallEvent = {
|
|
1385
|
-
type:
|
|
1896
|
+
type: 'schedule',
|
|
1386
1897
|
// Route by schedule_id (not attached chat_id) so concurrent fires of
|
|
1387
1898
|
// different schedules can fork independently — matches the PR1 primitive
|
|
1388
1899
|
// design where "schedule triggers; target decides response" and fire
|
|
1389
1900
|
// semantics are independent of any attached conversation.
|
|
1390
1901
|
targetId: run.schedule_id,
|
|
1391
|
-
targetType:
|
|
1392
|
-
senderId: actorId ??
|
|
1393
|
-
senderName:
|
|
1902
|
+
targetType: 'schedule',
|
|
1903
|
+
senderId: actorId ?? 'system',
|
|
1904
|
+
senderName: 'schedule',
|
|
1394
1905
|
messageId: run.id,
|
|
1395
|
-
body: run.fired_description ??
|
|
1906
|
+
body: run.fired_description ?? '',
|
|
1396
1907
|
scheduledFireAt: run.scheduled_fire_at,
|
|
1397
1908
|
attachedUri: run.fired_attached_uri ?? undefined,
|
|
1398
|
-
ackSourceType:
|
|
1909
|
+
ackSourceType: 'schedule_run',
|
|
1399
1910
|
ackSourceId: run.id,
|
|
1400
1911
|
};
|
|
1401
1912
|
|
|
@@ -1412,17 +1923,25 @@ export class ParallAgentGateway {
|
|
|
1412
1923
|
return dispatched;
|
|
1413
1924
|
}
|
|
1414
1925
|
|
|
1415
|
-
private async fetchAndHandleApprovalDecided(
|
|
1926
|
+
private async fetchAndHandleApprovalDecided(
|
|
1927
|
+
approvalId: string,
|
|
1928
|
+
actorId: string | null,
|
|
1929
|
+
chatId: string | null,
|
|
1930
|
+
): Promise<boolean> {
|
|
1416
1931
|
let approval: Approval | null = null;
|
|
1417
1932
|
try {
|
|
1418
1933
|
approval = await this.opts.client.getApproval(approvalId);
|
|
1419
1934
|
} catch (err: unknown) {
|
|
1420
1935
|
const status = (err as { status?: number })?.status;
|
|
1421
1936
|
if (status === 404 || status === 403) {
|
|
1422
|
-
this.opts.log?.warn(
|
|
1937
|
+
this.opts.log?.warn(
|
|
1938
|
+
`approval ${approvalId} not accessible (${status}), acking stale dispatch`,
|
|
1939
|
+
);
|
|
1423
1940
|
return true;
|
|
1424
1941
|
}
|
|
1425
|
-
this.opts.log?.warn(
|
|
1942
|
+
this.opts.log?.warn(
|
|
1943
|
+
`approval fetch failed for ${approvalId}, leaving pending: ${String(err)}`,
|
|
1944
|
+
);
|
|
1426
1945
|
return false;
|
|
1427
1946
|
}
|
|
1428
1947
|
if (!approval) return true;
|
|
@@ -1433,15 +1952,15 @@ export class ParallAgentGateway {
|
|
|
1433
1952
|
this.dispatchedTasks.add(dedupeKey);
|
|
1434
1953
|
this.opts.log?.info(`approval decided: ${approval.id} (${approval.status})`);
|
|
1435
1954
|
|
|
1436
|
-
const statusLabel = approval.status ===
|
|
1437
|
-
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}` : '';
|
|
1438
1957
|
const body = `${statusLabel}: ${approval.title}${execInfo}`;
|
|
1439
1958
|
|
|
1440
1959
|
const event: ParallEvent = {
|
|
1441
|
-
type:
|
|
1960
|
+
type: 'approval',
|
|
1442
1961
|
targetId: chatId ?? approval.chat_id,
|
|
1443
|
-
senderId: actorId ?? approval.decided_by ??
|
|
1444
|
-
senderName:
|
|
1962
|
+
senderId: actorId ?? approval.decided_by ?? 'system',
|
|
1963
|
+
senderName: 'approver',
|
|
1445
1964
|
messageId: approval.id,
|
|
1446
1965
|
body,
|
|
1447
1966
|
};
|
|
@@ -1459,11 +1978,9 @@ export class ParallAgentGateway {
|
|
|
1459
1978
|
return dispatched;
|
|
1460
1979
|
}
|
|
1461
1980
|
|
|
1462
|
-
private async catchUpFromDispatch(
|
|
1463
|
-
const minAge = coldStart ? Date.now() - this.COLD_START_WINDOW_MS : 0;
|
|
1981
|
+
private async catchUpFromDispatch() {
|
|
1464
1982
|
let cursor: string | undefined;
|
|
1465
1983
|
let processed = 0;
|
|
1466
|
-
let skippedOld = 0;
|
|
1467
1984
|
|
|
1468
1985
|
do {
|
|
1469
1986
|
const page = await this.opts.client.getDispatch(this.opts.config.org_id, {
|
|
@@ -1475,36 +1992,57 @@ export class ParallAgentGateway {
|
|
|
1475
1992
|
// Shutdown short-circuit: stop fetching/building work that runDispatch
|
|
1476
1993
|
// will only reject. Items remain unacked for the replacement pod.
|
|
1477
1994
|
if (this.shuttingDown) break;
|
|
1478
|
-
if (minAge > 0 && new Date(item.created_at).getTime() < minAge) {
|
|
1479
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
|
|
1480
|
-
skippedOld++;
|
|
1481
|
-
continue;
|
|
1482
|
-
}
|
|
1483
1995
|
|
|
1484
1996
|
processed++;
|
|
1485
1997
|
try {
|
|
1486
1998
|
let dispatched = false;
|
|
1487
|
-
if (item.event_type ===
|
|
1999
|
+
if (item.event_type === 'task_assign' && item.task_id) {
|
|
1488
2000
|
try {
|
|
1489
|
-
dispatched = await this.handleTaskDispatch(
|
|
2001
|
+
dispatched = await this.handleTaskDispatch(
|
|
2002
|
+
item.task_id,
|
|
2003
|
+
item.source_id ?? item.task_id,
|
|
2004
|
+
);
|
|
1490
2005
|
} catch (err: unknown) {
|
|
1491
|
-
this.opts.log?.warn(
|
|
2006
|
+
this.opts.log?.warn(
|
|
2007
|
+
`catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`,
|
|
2008
|
+
);
|
|
1492
2009
|
continue;
|
|
1493
2010
|
}
|
|
1494
|
-
} else if (item.event_type ===
|
|
2011
|
+
} else if (item.event_type === 'task_update' && item.task_id) {
|
|
1495
2012
|
try {
|
|
1496
|
-
dispatched = await this.handleTaskDispatch(
|
|
2013
|
+
dispatched = await this.handleTaskDispatch(
|
|
2014
|
+
item.task_id,
|
|
2015
|
+
item.source_id ?? item.task_id,
|
|
2016
|
+
{ allowCreator: true },
|
|
2017
|
+
);
|
|
1497
2018
|
} catch (err: unknown) {
|
|
1498
|
-
this.opts.log?.warn(
|
|
2019
|
+
this.opts.log?.warn(
|
|
2020
|
+
`catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`,
|
|
2021
|
+
);
|
|
1499
2022
|
continue;
|
|
1500
2023
|
}
|
|
1501
|
-
} else if (item.event_type ===
|
|
1502
|
-
dispatched = await this.handleTaskComment(
|
|
1503
|
-
|
|
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) {
|
|
1504
2038
|
dispatched = await this.fetchAndHandleScheduleFire(item.source_id, item.actor_id);
|
|
1505
|
-
} else if (item.event_type ===
|
|
1506
|
-
dispatched = await this.fetchAndHandleApprovalDecided(
|
|
1507
|
-
|
|
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) {
|
|
1508
2046
|
if (!this.tryClaimMessage(item.source_id)) continue;
|
|
1509
2047
|
let msg: Awaited<ReturnType<typeof this.opts.client.getMessage>> | null = null;
|
|
1510
2048
|
let msgFetchFailed = false;
|
|
@@ -1516,7 +2054,9 @@ export class ParallAgentGateway {
|
|
|
1516
2054
|
msg = null;
|
|
1517
2055
|
} else {
|
|
1518
2056
|
msgFetchFailed = true;
|
|
1519
|
-
this.opts.log?.warn(
|
|
2057
|
+
this.opts.log?.warn(
|
|
2058
|
+
`catch-up message fetch failed for ${item.source_id}, leaving pending: ${String(err)}`,
|
|
2059
|
+
);
|
|
1520
2060
|
}
|
|
1521
2061
|
}
|
|
1522
2062
|
if (msgFetchFailed) {
|
|
@@ -1530,11 +2070,11 @@ export class ParallAgentGateway {
|
|
|
1530
2070
|
}
|
|
1531
2071
|
|
|
1532
2072
|
const decision = await this.buildMessageDispatchDecision(item.chat_id, msg);
|
|
1533
|
-
if (decision.action ===
|
|
2073
|
+
if (decision.action === 'retry') {
|
|
1534
2074
|
this.dispatchedMessages.delete(item.source_id);
|
|
1535
2075
|
continue;
|
|
1536
2076
|
}
|
|
1537
|
-
if (decision.action ===
|
|
2077
|
+
if (decision.action === 'skip') {
|
|
1538
2078
|
this.dispatchedMessages.delete(item.source_id);
|
|
1539
2079
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
|
|
1540
2080
|
continue;
|
|
@@ -1546,7 +2086,9 @@ export class ParallAgentGateway {
|
|
|
1546
2086
|
this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
|
|
1547
2087
|
}
|
|
1548
2088
|
} catch (err) {
|
|
1549
|
-
this.opts.log?.warn(
|
|
2089
|
+
this.opts.log?.warn(
|
|
2090
|
+
`catch-up dispatch ${item.id} (${item.event_type}) failed: ${String(err)}`,
|
|
2091
|
+
);
|
|
1550
2092
|
}
|
|
1551
2093
|
}
|
|
1552
2094
|
// Stop paginating once shutdown begins — the inner loop already broke,
|
|
@@ -1554,14 +2096,21 @@ export class ParallAgentGateway {
|
|
|
1554
2096
|
cursor = !this.shuttingDown && page.has_more ? page.next_cursor : undefined;
|
|
1555
2097
|
} while (cursor);
|
|
1556
2098
|
|
|
1557
|
-
if (processed > 0
|
|
1558
|
-
this.opts.log?.info(`dispatch catch-up: processed ${processed}
|
|
2099
|
+
if (processed > 0) {
|
|
2100
|
+
this.opts.log?.info(`dispatch catch-up: processed ${processed}`);
|
|
1559
2101
|
}
|
|
1560
2102
|
}
|
|
1561
2103
|
|
|
1562
2104
|
private async handleHello(data: HelloData) {
|
|
1563
2105
|
const { client, config, log } = this.opts;
|
|
1564
|
-
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
|
+
}
|
|
1565
2114
|
const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
|
|
1566
2115
|
try {
|
|
1567
2116
|
const count = await fetchAllChats(client, config.org_id, this.chatInfoMap);
|
|
@@ -1583,7 +2132,7 @@ export class ParallAgentGateway {
|
|
|
1583
2132
|
log?.warn(`heartbeat drift ${drift}ms — event loop may be blocked`);
|
|
1584
2133
|
}
|
|
1585
2134
|
this.lastHeartbeatAt = now;
|
|
1586
|
-
if (this.opts.ws.state !==
|
|
2135
|
+
if (this.opts.ws.state !== 'connected') return;
|
|
1587
2136
|
this.opts.ws.sendAgentHeartbeat(this.sessionId, {
|
|
1588
2137
|
hostname: os.hostname(),
|
|
1589
2138
|
cores: os.cpus().length,
|
|
@@ -1593,9 +2142,7 @@ export class ParallAgentGateway {
|
|
|
1593
2142
|
});
|
|
1594
2143
|
}, intervalSec * 1000);
|
|
1595
2144
|
|
|
1596
|
-
|
|
1597
|
-
this.hadSuccessfulHello = true;
|
|
1598
|
-
this.catchUpFromDispatch(isFirstHello).catch((err) => {
|
|
2145
|
+
this.catchUpFromDispatch().catch((err) => {
|
|
1599
2146
|
log?.warn(`dispatch catch-up failed: ${String(err)}`);
|
|
1600
2147
|
});
|
|
1601
2148
|
} catch (err) {
|
|
@@ -1647,10 +2194,6 @@ export class ParallAgentGateway {
|
|
|
1647
2194
|
}
|
|
1648
2195
|
|
|
1649
2196
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
1650
|
-
for (const [, dispatch] of this.activeDispatches) {
|
|
1651
|
-
clearInterval(dispatch.typingTimer);
|
|
1652
|
-
}
|
|
1653
|
-
this.activeDispatches.clear();
|
|
1654
2197
|
|
|
1655
2198
|
await this.opts.onBeforeDisconnect?.();
|
|
1656
2199
|
|