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