@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.
Files changed (72) hide show
  1. package/dist/bridge-workspace.d.ts +1 -1
  2. package/dist/bridge-workspace.d.ts.map +1 -1
  3. package/dist/bridge-workspace.js +13 -13
  4. package/dist/dispatch-adapter.d.ts +15 -8
  5. package/dist/dispatch-adapter.d.ts.map +1 -1
  6. package/dist/event-format.d.ts +1 -1
  7. package/dist/event-format.d.ts.map +1 -1
  8. package/dist/event-format.js +68 -25
  9. package/dist/gateway-base.d.ts +14 -13
  10. package/dist/gateway-base.d.ts.map +1 -1
  11. package/dist/gateway-base.js +650 -313
  12. package/dist/index.d.ts +15 -13
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +13 -12
  15. package/dist/internal/attachment-input.d.ts +3 -3
  16. package/dist/internal/attachment-input.d.ts.map +1 -1
  17. package/dist/internal/attachment-input.js +61 -58
  18. package/dist/logger.d.ts +1 -1
  19. package/dist/platform-config.d.ts +28 -2
  20. package/dist/platform-config.d.ts.map +1 -1
  21. package/dist/platform-config.js +42 -11
  22. package/dist/prompt-fragments.d.ts +2 -2
  23. package/dist/prompt-fragments.d.ts.map +1 -1
  24. package/dist/prompt-fragments.js +37 -14
  25. package/dist/provider-config.d.ts +9 -0
  26. package/dist/provider-config.d.ts.map +1 -1
  27. package/dist/provider-config.js +13 -2
  28. package/dist/routing.d.ts +5 -5
  29. package/dist/routing.js +6 -6
  30. package/dist/session-state.d.ts +16 -0
  31. package/dist/session-state.d.ts.map +1 -1
  32. package/dist/session-state.js +45 -0
  33. package/dist/skills/index.d.ts +5 -4
  34. package/dist/skills/index.d.ts.map +1 -1
  35. package/dist/skills/index.js +28 -21
  36. package/dist/skills/parall-clips.d.ts +2 -0
  37. package/dist/skills/parall-clips.d.ts.map +1 -0
  38. package/dist/skills/parall-clips.js +44 -0
  39. package/dist/skills/parall-platform.d.ts +1 -1
  40. package/dist/skills/parall-platform.d.ts.map +1 -1
  41. package/dist/skills/parall-platform.js +6 -2
  42. package/dist/skills/parall-tasks.d.ts +1 -1
  43. package/dist/skills/parall-tasks.d.ts.map +1 -1
  44. package/dist/skills/parall-tasks.js +1 -1
  45. package/dist/skills/parall-wiki.d.ts +1 -1
  46. package/dist/skills/parall-wiki.d.ts.map +1 -1
  47. package/dist/skills/parall-wiki.js +1 -1
  48. package/dist/telemetry.d.ts +27 -0
  49. package/dist/telemetry.d.ts.map +1 -0
  50. package/dist/telemetry.js +205 -0
  51. package/dist/types.d.ts +18 -2
  52. package/dist/types.d.ts.map +1 -1
  53. package/package.json +11 -2
  54. package/src/bridge-workspace.ts +13 -13
  55. package/src/dispatch-adapter.ts +31 -8
  56. package/src/event-format.ts +80 -30
  57. package/src/gateway-base.ts +988 -445
  58. package/src/index.ts +23 -13
  59. package/src/internal/attachment-input.ts +127 -100
  60. package/src/logger.ts +1 -1
  61. package/src/platform-config.ts +61 -16
  62. package/src/prompt-fragments.ts +37 -14
  63. package/src/provider-config.ts +14 -2
  64. package/src/routing.ts +11 -11
  65. package/src/session-state.ts +62 -0
  66. package/src/skills/index.ts +34 -23
  67. package/src/skills/parall-clips.ts +44 -0
  68. package/src/skills/parall-platform.ts +6 -2
  69. package/src/skills/parall-tasks.ts +1 -1
  70. package/src/skills/parall-wiki.ts +1 -1
  71. package/src/telemetry.ts +252 -0
  72. package/src/types.ts +18 -2
@@ -1,7 +1,7 @@
1
- import * as os from "node:os";
2
- import * as fs from "node:fs";
3
- import * as path from "node:path";
4
- import { MENTION_ALL_USER_ID, ParallClient, ParallWs } from "@parall/sdk";
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 "@parall/sdk";
22
- import { buildEventBody, buildEventBodyForForkResult, buildForkResultPrefix, buildForkScopePrefix } from "./event-format.js";
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 "./dispatch-adapter.js";
31
- import { routeTrigger } from "./routing.js";
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 "./session-state.js";
41
- import type { DispatchState, ParallEvent } from "./types.js";
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(["open", "active", "idle"]);
67
+ const LIVE_SESSION_STATUSES = new Set(['open', 'active', 'idle']);
44
68
 
45
69
  type ChatInfo = {
46
- type: Chat["type"];
70
+ type: Chat['type'];
47
71
  name: string | null;
48
- agentRoutingMode: Chat["agent_routing_mode"];
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: "dispatch"; event: ParallEvent }
82
- | { action: "skip" }
83
- | { action: "retry" };
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: { activeSessionId?: string; ws: ParallWs; runtimeKey: string }) => Promise<void> | void;
111
- onSessionBinding?: (state: { sessionKey: string; agentSessionId: string; runtimeSessionId: string; runtimeLaneKey: string }) => Promise<void> | void;
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 === "task" || event.targetId.startsWith("tsk_")) {
142
- return { target_type: "task", target_id: event.targetId };
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("cht_")) {
145
- return { target_type: "chat", target_id: event.targetId };
191
+ if (event.targetId.startsWith('cht_')) {
192
+ return { target_type: 'chat', target_id: event.targetId };
146
193
  }
147
- if (event.type === "schedule" || event.targetId.startsWith("sch_")) {
148
- return { target_type: "schedule", target_id: event.targetId };
194
+ if (event.type === 'schedule' || event.targetId.startsWith('sch_')) {
195
+ return { target_type: 'schedule', target_id: event.targetId };
149
196
  }
150
- return { target_type: "", target_id: event.targetId || undefined };
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
- private hadSuccessfulHello = false;
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("hello", async (data: HelloData) => {
320
+ ws.on('hello', async (data: HelloData) => {
226
321
  await this.handleHello(data);
227
322
  });
228
323
 
229
- ws.on("chat.update", (data: ChatUpdateData) => {
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 === "string" ? { type: changes.type as Chat["type"] } : {}),
237
- ...(typeof changes.name === "string" ? { name: changes.name } : {}),
238
- ...(typeof changes.agent_routing_mode === "string"
239
- ? { agentRoutingMode: changes.agent_routing_mode as Chat["agent_routing_mode"] }
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 === "string") {
337
+ } else if (typeof changes.type === 'string') {
243
338
  this.chatInfoMap.set(data.chat_id, {
244
- type: changes.type as Chat["type"],
245
- name: typeof changes.name === "string" ? changes.name : null,
246
- agentRoutingMode: (typeof changes.agent_routing_mode === "string"
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
- : "passive") as Chat["agent_routing_mode"],
343
+ : 'passive') as Chat['agent_routing_mode'],
249
344
  });
250
345
  }
251
346
  });
252
347
 
253
- ws.on("message.new", async (data: MessageNewData) => {
348
+ ws.on('message.new', async (data: MessageNewData) => {
254
349
  await this.handleMessage(data);
255
350
  });
256
351
 
257
- ws.on("agent_config.update", async (data: AgentConfigUpdateData) => {
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("agent.new_session", async (data: AgentNewSessionData) => {
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("recovery.overflow", () => {
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("task.assigned", async (data: TaskAssignedData) => {
382
+ ws.on('task.assigned', async (data: TaskAssignedData) => {
288
383
  if (data.assignee_id !== this.opts.agentUserId) return;
289
- if (data.status !== "todo" && data.status !== "in_progress") return;
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.ackDispatch(this.opts.config.org_id, { source_type: "task_activity", source_id: data.id }).catch(() => {});
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("dispatch.new", async (data: DispatchNewData) => {
301
- if (data.event_type === "task_comment") {
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(data.source_id, data.task_id, data.actor_id, data.delivery_reason);
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(`task comment dispatch failed for ${data.source_id}: ${String(err)}`);
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 === "task_update") {
434
+ } else if (data.event_type === 'task_update') {
312
435
  if (!data.task_id) return;
313
436
  try {
314
- const dispatched = await this.handleTaskDispatch(data.task_id, data.source_id ?? data.task_id, { allowCreator: true });
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 === "schedule.fire") {
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(`schedule fire dispatch failed for ${data.source_id}: ${String(err)}`);
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 === "approval_decided") {
460
+ } else if (data.event_type === 'approval_decided') {
332
461
  if (!data.source_id) return;
333
462
  try {
334
- const dispatched = await this.fetchAndHandleApprovalDecided(data.source_id, data.actor_id, data.chat_id ?? null);
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(`approval decided dispatch failed for ${data.source_id}: ${String(err)}`);
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 !== "message" && data.event_type !== "task_assign") {
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(`dispatch.new with unhandled event_type=${String(data.event_type)} (id=${data.id}) — no-op`);
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 ?? "Parall WS"}...`);
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("abort", async () => {
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 startTyping(chatId: string) {
378
- const existing = this.activeDispatches.get(chatId);
379
- if (existing) {
380
- existing.count++;
381
- return;
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: (event.type === "message" || event.type === "approval") ? event.targetId : undefined,
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(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
463
- step_type: "input",
464
- target_type: target.target_type,
465
- target_id: target.target_id,
466
- content: {
467
- trigger_type:
468
- event.type === "task" ? "task_assign" :
469
- event.type === "task_comment" ? "task_comment" :
470
- event.type === "schedule" ? "schedule_fire" :
471
- event.type === "approval" ? "approval_decided" :
472
- "mention",
473
- trigger_ref:
474
- event.type === "task" ? { task_id: event.targetId } :
475
- event.type === "task_comment" ? { comment_id: event.messageId, task_id: event.targetId } :
476
- event.type === "schedule" ? { schedule_id: event.targetId, run_id: event.messageId } :
477
- event.type === "approval" ? { approval_id: event.messageId } :
478
- { message_id: event.messageId },
479
- sender_id: event.senderId,
480
- sender_name: event.senderName,
481
- summary: event.body.substring(0, 200),
482
- ...(event.sentAt ? { sent_at: event.sentAt } : {}),
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(sessionId: string, event: ParallEvent, runtimeEvent: RuntimeEvent, stepIdFilePath?: string, contextFilePath?: string) {
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 "thinking":
496
- await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
497
- step_type: "thinking",
498
- target_type: target.target_type,
499
- target_id: target.target_id,
500
- content: { text: runtimeEvent.text },
501
- group_key: runtimeEvent.groupKey,
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 "text":
506
- await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
507
- step_type: "text",
508
- target_type: target.target_type,
509
- target_id: target.target_id,
510
- content: {
511
- text: runtimeEvent.text,
512
- suppressed: runtimeEvent.project !== true,
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
- projection: runtimeEvent.project === true,
515
- group_key: runtimeEvent.groupKey,
516
- });
642
+ );
517
643
  break;
518
644
 
519
- case "tool_call": {
520
- const step = await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
521
- step_type: "tool_call",
522
- target_type: target.target_type,
523
- target_id: target.target_id,
524
- content: {
525
- call_id: runtimeEvent.callId,
526
- tool_name: runtimeEvent.toolName,
527
- tool_input: runtimeEvent.input,
528
- status: "running",
529
- started_at: runtimeEvent.startedAt ?? new Date().toISOString(),
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
- group_key: runtimeEvent.groupKey,
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 "tool_result":
543
- await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
544
- step_type: "tool_result",
545
- target_type: target.target_type,
546
- target_id: target.target_id,
547
- content: {
548
- call_id: runtimeEvent.callId,
549
- tool_name: runtimeEvent.toolName,
550
- status: runtimeEvent.error ? "error" : "success",
551
- output: runtimeEvent.output,
552
- duration_ms: runtimeEvent.durationMs ?? 0,
553
- collapsible: true,
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
- group_key: runtimeEvent.groupKey,
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 "error":
565
- await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
566
- step_type: "text",
567
- target_type: target.target_type,
568
- target_id: target.target_id,
569
- content: { text: runtimeEvent.message, suppressed: false },
570
- projection: false,
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), "utf8");
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, "utf8");
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), "utf8");
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, "utf8");
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), "utf8");
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, "utf8");
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, "", "utf8");
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: "runtime_session" }>,
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 = sessionKey === this.opts.runtimeKey
651
- ? undefined
652
- : this.sessionBindings.get(this.opts.runtimeKey)?.agentSessionId;
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(this.opts.config.org_id, this.opts.agentUserId, {
658
- runtime_type: this.opts.runtimeType,
659
- runtime_key: runtimeLaneKey,
660
- runtime_lane_key: runtimeLaneKey,
661
- runtime_session_id: runtimeEvent.runtimeSessionId,
662
- parent_session_id: parentSessionId,
663
- runtime_ref: Object.keys(runtimeRef).length > 0 ? runtimeRef : undefined,
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 { await this.opts.onSessionStale?.(sessionKey); } catch (e) { this.opts.log?.warn?.(`onSessionStale failed: ${e}`); }
671
- this.opts.log?.info?.(`stale session self-heal complete for ${sessionKey} — next dispatch will create a fresh session`);
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 + "\n\n---\n\n" + bodyForAgent;
868
+ bodyForAgent = this.pendingRestartNotification + '\n\n---\n\n' + bodyForAgent;
716
869
  this.pendingRestartNotification = null;
717
870
  }
718
871
 
719
- setSessionChatId(sessionKey, event.targetId);
720
- setSessionMessageId(sessionKey, event.messageId);
721
- setDispatchMessageId(sessionKey, event.messageId);
722
- setDispatchNoReply(sessionKey, event.noReply ?? false);
872
+ resetDispatchMetrics(sessionKey);
873
+ return runWithSessionKey(sessionKey, async () => {
874
+ let dispatchSpan: ReturnType<typeof startDispatchSpan> = null;
723
875
 
724
- const dispatchContext = this.buildDispatchContext(event, sessionKey);
725
- const contextFilePath = dispatchContext.contextFilePath;
726
- const stepIdFilePath = dispatchContext.stepIdFilePath;
876
+ setSessionChatId(sessionKey, event.targetId);
877
+ setSessionMessageId(sessionKey, event.messageId);
878
+ setDispatchMessageId(sessionKey, event.messageId);
879
+ setDispatchNoReply(sessionKey, event.noReply ?? false);
727
880
 
728
- if (contextFilePath) {
729
- this.writeContextFile(contextFilePath, {
730
- session_id: dispatchContext.sessionId ?? null,
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
- // sync: no await between the shuttingDown check above and this increment
739
- // — JS event loop is single-threaded, so shutdown() cannot interleave
740
- // here and miss our in-flight count.
741
- this.inFlightDispatches++;
742
- let binding = this.sessionBindings.get(sessionKey);
743
- let inputStepsCreated = false;
744
- let triggerMessageSet = false;
745
- try {
746
- for await (const runtimeEvent of this.opts.dispatchAdapter.dispatch({
747
- event,
748
- earlierEvents,
749
- bodyForAgent,
750
- sessionKey,
751
- context: dispatchContext,
752
- })) {
753
- if (runtimeEvent.type === "runtime_session") {
754
- binding = await this.bindRuntimeSession(sessionKey, runtimeEvent, contextFilePath);
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
- continue;
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
- const detail = runtimeEvent.type === "error" ? `: ${runtimeEvent.message}` : "";
771
- throw new Error(`runtime emitted ${runtimeEvent.type} before runtime_session${detail}`);
994
+ binding = this.sessionBindings.get(sessionKey);
772
995
  }
773
- if (!triggerMessageSet) {
774
- triggerMessageSet = true;
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
- if (captureText && runtimeEvent.type === "text" && runtimeEvent.text) {
788
- captureText.push(runtimeEvent.text);
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
- await this.createRuntimeStep(binding.agentSessionId, event, runtimeEvent, stepIdFilePath, contextFilePath);
791
- }
792
- if (!binding) {
793
- binding = this.sessionBindings.get(sessionKey);
794
- }
795
- if (!binding) {
796
- throw new Error("runtime completed without runtime_session");
797
- }
798
- if (!inputStepsCreated) {
799
- if (earlierEvents.length > 0) {
800
- await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
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.runDispatchWithTyping(last, fork.fork.sessionKey, buildForkScopePrefix(last) + buildEventBody(last), earlier, batchText);
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("").trim() || undefined;
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: fork.processedEvents.length === 1
898
- ? `${first.type} from ${first.senderName} in ${first.targetName ?? fork.targetId}`
899
- : `${fork.processedEvents.length} events in ${first.targetName ?? fork.targetId}`,
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.delete(fork.targetId);
908
- this.dispatchState.activeForks.delete(fork.targetId);
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
- await this.opts.dispatchAdapter.cleanupFork(cleanupOpts);
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.updateAgentSession(
931
- this.opts.config.org_id, this.opts.agentUserId, forkBinding.agentSessionId,
932
- { status: "closed" },
933
- ).catch(() => {});
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 = this.opts.dispatchAdapter.hasPendingInjections?.(this.opts.runtimeKey) ?? false;
964
- const injectedTypingCount = hasPendingInjections ? this.takeInjectedTypingCount(event.targetId) : 0;
965
- const pendingFork = hasPendingInjections ? [] : this.dispatchState.pendingForkResults.splice(0);
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.dispatchAdapter.getBranchPoint?.(this.opts.runtimeKey);
970
- const dispatched = await this.runDispatchWithTyping(
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 = bufferedEvent.ackSourceType ?? (bufferedEvent.type === "task" ? "task_activity" : "message");
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.ackDispatch(this.opts.config.org_id, {
990
- source_type: sourceType,
991
- source_id: sourceId,
992
- }).catch(() => {});
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 "main": {
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.dispatchAdapter.getBranchPoint?.(this.opts.runtimeKey);
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.runDispatchWithTyping(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event));
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 "buffer-main": {
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?.(this.opts.runtimeKey, buildEventBody(event))
1412
+ (await this.opts.dispatchAdapter.enqueueDuringDispatch?.(
1413
+ this.opts.runtimeKey,
1414
+ buildEventBody(event),
1415
+ ))
1042
1416
  ) {
1043
- this.startInjectedTyping(event);
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 (!this.dispatchState.mainDispatching && !this.draining && this.dispatchState.mainBuffer.length > 0) {
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 "buffer-fork": {
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 "new-fork": {
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 !== "text") {
1130
- return { action: "skip" };
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: "retry" };
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: "skip" };
1530
+ if (!body && !hasAttachments) return { action: 'skip' };
1141
1531
 
1142
- if (chatInfo.type === "group" && chatInfo.agentRoutingMode !== "active") {
1532
+ if (chatInfo.type === 'group' && chatInfo.agentRoutingMode !== 'active') {
1143
1533
  const mentions = content.mentions ?? [];
1144
1534
  const isMentioned = mentions.some(
1145
- (mention) => mention.user_id === this.opts.agentUserId || mention.user_id === MENTION_ALL_USER_ID,
1535
+ (mention) =>
1536
+ mention.user_id === this.opts.agentUserId || mention.user_id === MENTION_ALL_USER_ID,
1146
1537
  );
1147
- if (!isMentioned) return { action: "skip" };
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: "dispatch",
1578
+ action: 'dispatch',
1159
1579
  event: {
1160
- type: "message",
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 || "[attachment]",
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: "message",
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 !== "text") return;
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 !== "dispatch") {
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.ackDispatch(this.opts.config.org_id, { source_type: "message", source_id: data.id }).catch(() => {});
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("", task.description);
1646
+ if (task.description) parts.push('', task.description);
1220
1647
 
1221
1648
  const event: ParallEvent = {
1222
- type: "task",
1649
+ type: 'task',
1223
1650
  targetId: task.id,
1224
1651
  targetName: task.identifier ?? undefined,
1225
- targetType: "task",
1652
+ targetType: 'task',
1226
1653
  senderId: task.creator_id,
1227
- senderName: "system",
1654
+ senderName: 'system',
1228
1655
  messageId: task.id,
1229
- body: parts.join("\n"),
1656
+ body: parts.join('\n'),
1230
1657
  sentAt: task.updated_at ?? task.created_at,
1231
- ackSourceType: "task_activity",
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 { /* task context is optional */ }
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 ?? "unknown"}`);
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(`Comment by: ${comment.author?.display_name ?? actorId ?? "unknown"} (prll://${comment.author_id})`);
1317
- parts.push("", comment.body);
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: "task_comment",
1751
+ type: 'task_comment',
1321
1752
  targetId: taskId,
1322
1753
  targetName: task?.identifier ?? undefined,
1323
- targetType: "task",
1754
+ targetType: 'task',
1324
1755
  senderId: comment.author_id,
1325
- senderName: comment.author?.display_name ?? actorId ?? "unknown",
1756
+ senderName: comment.author?.display_name ?? actorId ?? 'unknown',
1326
1757
  messageId: commentId,
1327
- body: parts.join("\n"),
1758
+ body: parts.join('\n'),
1328
1759
  deliveryReason: deliveryReason ?? undefined,
1329
- ackSourceType: "comment",
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(runId: string, actorId: string | null): Promise<boolean> {
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(`schedule run fetch failed for ${runId}, leaving pending: ${String(err)}`);
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: "schedule",
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: "schedule",
1392
- senderId: actorId ?? "system",
1393
- senderName: "schedule",
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: "schedule_run",
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(approvalId: string, actorId: string | null, chatId: string | null): Promise<boolean> {
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(`approval ${approvalId} not accessible (${status}), acking stale dispatch`);
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(`approval fetch failed for ${approvalId}, leaving pending: ${String(err)}`);
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 === "approved" ? "Approved" : "Rejected";
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: "approval",
1960
+ type: 'approval',
1442
1961
  targetId: chatId ?? approval.chat_id,
1443
- senderId: actorId ?? approval.decided_by ?? "system",
1444
- senderName: "approver",
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(coldStart = false) {
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 === "task_assign" && item.task_id) {
1999
+ if (item.event_type === 'task_assign' && item.task_id) {
1488
2000
  try {
1489
- dispatched = await this.handleTaskDispatch(item.task_id, item.source_id ?? item.task_id);
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(`catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
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 === "task_update" && item.task_id) {
2011
+ } else if (item.event_type === 'task_update' && item.task_id) {
1495
2012
  try {
1496
- dispatched = await this.handleTaskDispatch(item.task_id, item.source_id ?? item.task_id, { allowCreator: true });
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(`catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
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 === "task_comment" && item.source_id && item.task_id) {
1502
- dispatched = await this.handleTaskComment(item.source_id, item.task_id, item.actor_id, item.delivery_reason);
1503
- } else if (item.event_type === "schedule.fire" && item.source_id) {
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 === "approval_decided" && item.source_id) {
1506
- dispatched = await this.fetchAndHandleApprovalDecided(item.source_id, item.actor_id, item.chat_id ?? null);
1507
- } else if (item.event_type === "message" && item.source_id && item.chat_id) {
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(`catch-up message fetch failed for ${item.source_id}, leaving pending: ${String(err)}`);
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 === "retry") {
2073
+ if (decision.action === 'retry') {
1534
2074
  this.dispatchedMessages.delete(item.source_id);
1535
2075
  continue;
1536
2076
  }
1537
- if (decision.action === "skip") {
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(`catch-up dispatch ${item.id} (${item.event_type}) failed: ${String(err)}`);
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 || skippedOld > 0) {
1558
- this.opts.log?.info(`dispatch catch-up: processed ${processed}, skipped ${skippedOld} old item(s)`);
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 !== "connected") return;
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
- const isFirstHello = !this.hadSuccessfulHello;
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