@parall/agent-core 1.30.0 → 1.32.0

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