@parall/agent-core 1.31.0 → 1.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/bridge-workspace.d.ts +1 -1
  2. package/dist/bridge-workspace.d.ts.map +1 -1
  3. package/dist/bridge-workspace.js +13 -13
  4. package/dist/dispatch-adapter.d.ts +15 -8
  5. package/dist/dispatch-adapter.d.ts.map +1 -1
  6. package/dist/event-format.d.ts +1 -1
  7. package/dist/event-format.d.ts.map +1 -1
  8. package/dist/event-format.js +68 -25
  9. package/dist/gateway-base.d.ts +14 -13
  10. package/dist/gateway-base.d.ts.map +1 -1
  11. package/dist/gateway-base.js +650 -313
  12. package/dist/index.d.ts +15 -13
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +13 -12
  15. package/dist/internal/attachment-input.d.ts +3 -3
  16. package/dist/internal/attachment-input.d.ts.map +1 -1
  17. package/dist/internal/attachment-input.js +61 -58
  18. package/dist/logger.d.ts +1 -1
  19. package/dist/platform-config.d.ts +28 -2
  20. package/dist/platform-config.d.ts.map +1 -1
  21. package/dist/platform-config.js +42 -11
  22. package/dist/prompt-fragments.d.ts +2 -2
  23. package/dist/prompt-fragments.d.ts.map +1 -1
  24. package/dist/prompt-fragments.js +37 -14
  25. package/dist/provider-config.d.ts +9 -0
  26. package/dist/provider-config.d.ts.map +1 -1
  27. package/dist/provider-config.js +13 -2
  28. package/dist/routing.d.ts +5 -5
  29. package/dist/routing.js +6 -6
  30. package/dist/session-state.d.ts +16 -0
  31. package/dist/session-state.d.ts.map +1 -1
  32. package/dist/session-state.js +45 -0
  33. package/dist/skills/index.d.ts +5 -4
  34. package/dist/skills/index.d.ts.map +1 -1
  35. package/dist/skills/index.js +28 -21
  36. package/dist/skills/parall-clips.d.ts +2 -0
  37. package/dist/skills/parall-clips.d.ts.map +1 -0
  38. package/dist/skills/parall-clips.js +44 -0
  39. package/dist/skills/parall-platform.d.ts +1 -1
  40. package/dist/skills/parall-platform.d.ts.map +1 -1
  41. package/dist/skills/parall-platform.js +6 -2
  42. package/dist/skills/parall-tasks.d.ts +1 -1
  43. package/dist/skills/parall-tasks.d.ts.map +1 -1
  44. package/dist/skills/parall-tasks.js +1 -1
  45. package/dist/skills/parall-wiki.d.ts +1 -1
  46. package/dist/skills/parall-wiki.d.ts.map +1 -1
  47. package/dist/skills/parall-wiki.js +1 -1
  48. package/dist/telemetry.d.ts +27 -0
  49. package/dist/telemetry.d.ts.map +1 -0
  50. package/dist/telemetry.js +205 -0
  51. package/dist/types.d.ts +18 -2
  52. package/dist/types.d.ts.map +1 -1
  53. package/package.json +11 -2
  54. package/src/bridge-workspace.ts +13 -13
  55. package/src/dispatch-adapter.ts +31 -8
  56. package/src/event-format.ts +80 -30
  57. package/src/gateway-base.ts +988 -445
  58. package/src/index.ts +23 -13
  59. package/src/internal/attachment-input.ts +127 -100
  60. package/src/logger.ts +1 -1
  61. package/src/platform-config.ts +61 -16
  62. package/src/prompt-fragments.ts +37 -14
  63. package/src/provider-config.ts +14 -2
  64. package/src/routing.ts +11 -11
  65. package/src/session-state.ts +62 -0
  66. package/src/skills/index.ts +34 -23
  67. package/src/skills/parall-clips.ts +44 -0
  68. package/src/skills/parall-platform.ts +6 -2
  69. package/src/skills/parall-tasks.ts +1 -1
  70. package/src/skills/parall-wiki.ts +1 -1
  71. package/src/telemetry.ts +252 -0
  72. package/src/types.ts +18 -2
@@ -1,11 +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";
8
- const LIVE_SESSION_STATUSES = new Set(["open", "active", "idle"]);
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']);
9
11
  // Parse PRLL_SHUTDOWN_DEADLINE_MS (or any string env value) into a positive
10
12
  // integer milliseconds value, or undefined if unset/invalid. Runtimes pass
11
13
  // the result into ParallGatewayOptions.shutdownDeadlineMs; leaving it
@@ -22,17 +24,72 @@ export function parseShutdownDeadlineMs(raw) {
22
24
  return undefined;
23
25
  return Math.floor(n);
24
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
+ }
25
43
  function resolveStepTarget(event) {
26
- if (event.type === "task" || event.targetId.startsWith("tsk_")) {
27
- 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 };
28
46
  }
29
- if (event.targetId.startsWith("cht_")) {
30
- return { target_type: "chat", target_id: event.targetId };
47
+ if (event.targetId.startsWith('cht_')) {
48
+ return { target_type: 'chat', target_id: event.targetId };
31
49
  }
32
- if (event.type === "schedule" || event.targetId.startsWith("sch_")) {
33
- return { target_type: "schedule", target_id: event.targetId };
50
+ if (event.type === 'schedule' || event.targetId.startsWith('sch_')) {
51
+ return { target_type: 'schedule', target_id: event.targetId };
34
52
  }
35
- return { target_type: "", target_id: event.targetId || undefined };
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 };
59
+ }
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 };
36
93
  }
37
94
  async function fetchAllChats(client, orgId, chatInfoMap) {
38
95
  let cursor;
@@ -54,8 +111,6 @@ async function fetchAllChats(client, orgId, chatInfoMap) {
54
111
  export class ParallAgentGateway {
55
112
  opts;
56
113
  chatInfoMap = new Map();
57
- activeDispatches = new Map();
58
- injectedTypingCounts = new Map();
59
114
  dispatchedTasks = new Set();
60
115
  dispatchedMessages = new Set();
61
116
  forkStates = new Map();
@@ -65,11 +120,10 @@ export class ParallAgentGateway {
65
120
  pendingForkResults: [],
66
121
  mainBuffer: [],
67
122
  };
68
- sessionId = "";
123
+ sessionId = '';
69
124
  activeSessionId;
70
125
  sessionBindings = new Map();
71
126
  heartbeatTimer = null;
72
- hadSuccessfulHello = false;
73
127
  lastHeartbeatAt = Date.now();
74
128
  draining = false;
75
129
  // Graceful shutdown state. When SIGTERM / abort fires, `shuttingDown` flips
@@ -81,25 +135,30 @@ export class ParallAgentGateway {
81
135
  drainResolvers = [];
82
136
  pendingRestartNotification = null;
83
137
  DISPATCHED_MESSAGES_CAP = 5000;
84
- COLD_START_WINDOW_MS;
85
138
  // SHUTDOWN_DEADLINE_MS is read by waitForDrain via the configured value
86
139
  // below — kept as instance state so per-runtime configs can override it
87
140
  // (see parseShutdownDeadlineMs and runtime entrypoints).
88
141
  SHUTDOWN_DEADLINE_MS;
142
+ FORK_DEADLINE_MS;
143
+ DISPATCH_DEADLINE_MS;
89
144
  constructor(opts) {
90
145
  this.opts = opts;
91
- this.COLD_START_WINDOW_MS = opts.coldStartWindowMs ?? 5 * 60_000;
92
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
+ }
93
152
  }
94
153
  async run(abortSignal) {
95
154
  const { ws, log } = this.opts;
96
155
  ws.onStateChange((state) => {
97
156
  log?.info(`connection state → ${state}`);
98
157
  });
99
- ws.on("hello", async (data) => {
158
+ ws.on('hello', async (data) => {
100
159
  await this.handleHello(data);
101
160
  });
102
- ws.on("chat.update", (data) => {
161
+ ws.on('chat.update', (data) => {
103
162
  const changes = data.changes;
104
163
  if (!changes)
105
164
  return;
@@ -107,27 +166,27 @@ export class ParallAgentGateway {
107
166
  if (existing) {
108
167
  this.chatInfoMap.set(data.chat_id, {
109
168
  ...existing,
110
- ...(typeof changes.type === "string" ? { type: changes.type } : {}),
111
- ...(typeof changes.name === "string" ? { name: changes.name } : {}),
112
- ...(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'
113
172
  ? { agentRoutingMode: changes.agent_routing_mode }
114
173
  : {}),
115
174
  });
116
175
  }
117
- else if (typeof changes.type === "string") {
176
+ else if (typeof changes.type === 'string') {
118
177
  this.chatInfoMap.set(data.chat_id, {
119
178
  type: changes.type,
120
- name: typeof changes.name === "string" ? changes.name : null,
121
- agentRoutingMode: (typeof changes.agent_routing_mode === "string"
179
+ name: typeof changes.name === 'string' ? changes.name : null,
180
+ agentRoutingMode: (typeof changes.agent_routing_mode === 'string'
122
181
  ? changes.agent_routing_mode
123
- : "passive"),
182
+ : 'passive'),
124
183
  });
125
184
  }
126
185
  });
127
- ws.on("message.new", async (data) => {
186
+ ws.on('message.new', async (data) => {
128
187
  await this.handleMessage(data);
129
188
  });
130
- ws.on("agent_config.update", async (data) => {
189
+ ws.on('agent_config.update', async (data) => {
131
190
  this.opts.log?.info(`config update notification (version=${data.version})`);
132
191
  try {
133
192
  await this.opts.onConfigUpdate?.(data);
@@ -136,13 +195,12 @@ export class ParallAgentGateway {
136
195
  this.opts.log?.warn(`config update failed: ${String(err)}`);
137
196
  }
138
197
  });
139
- ws.on("agent.new_session", async (data) => {
140
- const prevId = data.previous_session_id ?? "";
198
+ ws.on('agent.new_session', async (data) => {
199
+ const prevId = data.previous_session_id ?? '';
141
200
  this.opts.log?.info(`new session signal received (previous=${prevId})`);
142
201
  this.sessionBindings.clear();
143
202
  if (prevId) {
144
- this.pendingRestartNotification =
145
- `[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.`;
146
204
  }
147
205
  try {
148
206
  await this.opts.onNewSession?.(prevId);
@@ -151,27 +209,32 @@ export class ParallAgentGateway {
151
209
  this.opts.log?.warn(`onNewSession callback failed: ${String(err)}`);
152
210
  }
153
211
  });
154
- ws.on("recovery.overflow", () => {
212
+ ws.on('recovery.overflow', () => {
155
213
  this.opts.log?.warn(`recovery.overflow — triggering full catch-up`);
156
214
  this.catchUpFromDispatch().catch((err) => this.opts.log?.warn(`overflow catch-up failed: ${String(err)}`));
157
215
  });
158
- ws.on("task.assigned", async (data) => {
216
+ ws.on('task.assigned', async (data) => {
159
217
  if (data.assignee_id !== this.opts.agentUserId)
160
218
  return;
161
- if (data.status !== "todo" && data.status !== "in_progress")
219
+ if (data.status !== 'todo' && data.status !== 'in_progress')
162
220
  return;
163
221
  try {
164
222
  const dispatched = await this.handleTaskAssignment(data, data.id);
165
223
  if (dispatched) {
166
- 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(() => { });
167
230
  }
168
231
  }
169
232
  catch (err) {
170
233
  this.opts.log?.error(`task dispatch failed for ${data.id}: ${String(err)}`);
171
234
  }
172
235
  });
173
- ws.on("dispatch.new", async (data) => {
174
- if (data.event_type === "task_comment") {
236
+ ws.on('dispatch.new', async (data) => {
237
+ if (data.event_type === 'task_comment') {
175
238
  if (!data.source_id || !data.task_id)
176
239
  return;
177
240
  try {
@@ -184,7 +247,20 @@ export class ParallAgentGateway {
184
247
  this.opts.log?.error(`task comment dispatch failed for ${data.source_id}: ${String(err)}`);
185
248
  }
186
249
  }
187
- 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') {
188
264
  if (!data.task_id)
189
265
  return;
190
266
  try {
@@ -197,7 +273,7 @@ export class ParallAgentGateway {
197
273
  this.opts.log?.error(`task update dispatch failed for ${data.task_id}: ${String(err)}`);
198
274
  }
199
275
  }
200
- else if (data.event_type === "schedule.fire") {
276
+ else if (data.event_type === 'schedule.fire') {
201
277
  if (!data.source_id)
202
278
  return;
203
279
  try {
@@ -210,7 +286,7 @@ export class ParallAgentGateway {
210
286
  this.opts.log?.error(`schedule fire dispatch failed for ${data.source_id}: ${String(err)}`);
211
287
  }
212
288
  }
213
- else if (data.event_type === "approval_decided") {
289
+ else if (data.event_type === 'approval_decided') {
214
290
  if (!data.source_id)
215
291
  return;
216
292
  try {
@@ -223,7 +299,7 @@ export class ParallAgentGateway {
223
299
  this.opts.log?.error(`approval decided dispatch failed for ${data.source_id}: ${String(err)}`);
224
300
  }
225
301
  }
226
- else if (data.event_type !== "message" && data.event_type !== "task_assign") {
302
+ else if (data.event_type !== 'message' && data.event_type !== 'task_assign') {
227
303
  // Truly unknown event_type — log so a newly-added dispatch type
228
304
  // not yet wired here surfaces during runtime testing. "message"
229
305
  // and "task_assign" are deliberately excluded: dispatch.new
@@ -234,10 +310,10 @@ export class ParallAgentGateway {
234
310
  this.opts.log?.info(`dispatch.new with unhandled event_type=${String(data.event_type)} (id=${data.id}) — no-op`);
235
311
  }
236
312
  });
237
- this.opts.log?.info(`connecting to ${this.opts.connectionLabel ?? "Parall WS"}...`);
313
+ this.opts.log?.info(`connecting to ${this.opts.connectionLabel ?? 'Parall WS'}...`);
238
314
  await ws.connect();
239
315
  return new Promise((resolve) => {
240
- abortSignal.addEventListener("abort", async () => {
316
+ abortSignal.addEventListener('abort', async () => {
241
317
  await this.shutdown();
242
318
  resolve();
243
319
  });
@@ -257,60 +333,13 @@ export class ParallAgentGateway {
257
333
  this.dispatchedMessages.add(id);
258
334
  return true;
259
335
  }
260
- startTyping(chatId) {
261
- const existing = this.activeDispatches.get(chatId);
262
- if (existing) {
263
- existing.count++;
264
- return;
265
- }
266
- if (this.opts.ws.state === "connected")
267
- this.opts.ws.sendTyping(chatId, "start");
268
- const typingRefresh = setInterval(() => {
269
- if (this.opts.ws.state === "connected")
270
- this.opts.ws.sendTyping(chatId, "start");
271
- }, 2000);
272
- this.activeDispatches.set(chatId, { count: 1, typingTimer: typingRefresh });
273
- }
274
- stopTyping(chatId) {
275
- const dispatch = this.activeDispatches.get(chatId);
276
- if (!dispatch)
277
- return;
278
- dispatch.count--;
279
- if (dispatch.count > 0)
280
- return;
281
- clearInterval(dispatch.typingTimer);
282
- this.activeDispatches.delete(chatId);
283
- if (this.opts.ws.state === "connected")
284
- this.opts.ws.sendTyping(chatId, "stop");
285
- }
286
- shouldShowTyping(event) {
287
- return event.type === "message" && event.targetId.startsWith("cht_") && !event.noReply;
288
- }
289
- startInjectedTyping(event) {
290
- if (!this.shouldShowTyping(event))
291
- return;
292
- this.startTyping(event.targetId);
293
- this.injectedTypingCounts.set(event.targetId, (this.injectedTypingCounts.get(event.targetId) ?? 0) + 1);
294
- }
295
- takeInjectedTypingCount(chatId) {
296
- const count = this.injectedTypingCounts.get(chatId) ?? 0;
297
- this.injectedTypingCounts.delete(chatId);
298
- return count;
299
- }
300
- async runDispatchWithTyping(event, sessionKey, bodyForAgent, earlierEvents = [], captureText, opts = {}) {
301
- const showTyping = !opts.suppressStart && (this.shouldShowTyping(event) || earlierEvents.some(e => this.shouldShowTyping(e)));
302
- if (showTyping)
303
- this.startTyping(event.targetId);
304
- try {
305
- return await this.runDispatch(event, sessionKey, bodyForAgent, earlierEvents, captureText);
306
- }
307
- finally {
308
- if (showTyping)
309
- this.stopTyping(event.targetId);
310
- for (let i = 0; i < (opts.injectedTypingCount ?? 0); i++) {
311
- this.stopTyping(event.targetId);
312
- }
313
- }
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
+ });
314
343
  }
315
344
  buildDispatchContext(event, sessionKey) {
316
345
  const binding = this.sessionBindings.get(sessionKey);
@@ -323,7 +352,7 @@ export class ParallAgentGateway {
323
352
  runtimeType: this.opts.runtimeType,
324
353
  runtimeKey: this.opts.runtimeKey,
325
354
  sessionId: binding?.agentSessionId,
326
- chatId: (event.type === "message" || event.type === "approval") ? event.targetId : undefined,
355
+ chatId: event.type === 'message' || event.type === 'approval' ? event.targetId : undefined,
327
356
  triggerMessageId: event.messageId,
328
357
  noReply: event.noReply ?? false,
329
358
  contextFilePath: this.opts.contextFilePathForSession?.(sessionKey),
@@ -332,24 +361,41 @@ export class ParallAgentGateway {
332
361
  log: this.opts.log,
333
362
  };
334
363
  }
364
+ isSessionNotLiveError(err) {
365
+ return (err instanceof ApiError &&
366
+ err.status === 409 &&
367
+ (err.code === 'SESSION_NOT_LIVE' || err.code === 'INVALID_TRANSITION'));
368
+ }
335
369
  async createInputStep(sessionId, event) {
336
370
  const target = resolveStepTarget(event);
337
371
  try {
338
372
  await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
339
- step_type: "input",
373
+ step_type: 'input',
340
374
  target_type: target.target_type,
341
375
  target_id: target.target_id,
342
376
  content: {
343
- trigger_type: event.type === "task" ? "task_assign" :
344
- event.type === "task_comment" ? "task_comment" :
345
- event.type === "schedule" ? "schedule_fire" :
346
- event.type === "approval" ? "approval_decided" :
347
- "mention",
348
- trigger_ref: event.type === "task" ? { task_id: event.targetId } :
349
- event.type === "task_comment" ? { comment_id: event.messageId, task_id: event.targetId } :
350
- event.type === "schedule" ? { schedule_id: event.targetId, run_id: event.messageId } :
351
- event.type === "approval" ? { approval_id: event.messageId } :
352
- { 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 },
353
399
  sender_id: event.senderId,
354
400
  sender_name: event.senderName,
355
401
  summary: event.body.substring(0, 200),
@@ -358,6 +404,8 @@ export class ParallAgentGateway {
358
404
  });
359
405
  }
360
406
  catch (err) {
407
+ if (this.isSessionNotLiveError(err))
408
+ throw err;
361
409
  this.opts.log?.warn(`failed to create input step: ${String(err)}`);
362
410
  }
363
411
  }
@@ -365,18 +413,18 @@ export class ParallAgentGateway {
365
413
  const target = resolveStepTarget(event);
366
414
  try {
367
415
  switch (runtimeEvent.type) {
368
- case "thinking":
416
+ case 'thinking':
369
417
  await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
370
- step_type: "thinking",
418
+ step_type: 'thinking',
371
419
  target_type: target.target_type,
372
420
  target_id: target.target_id,
373
421
  content: { text: runtimeEvent.text },
374
422
  group_key: runtimeEvent.groupKey,
375
423
  });
376
424
  break;
377
- case "text":
425
+ case 'text':
378
426
  await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
379
- step_type: "text",
427
+ step_type: 'text',
380
428
  target_type: target.target_type,
381
429
  target_id: target.target_id,
382
430
  content: {
@@ -387,16 +435,16 @@ export class ParallAgentGateway {
387
435
  group_key: runtimeEvent.groupKey,
388
436
  });
389
437
  break;
390
- case "tool_call": {
438
+ case 'tool_call': {
391
439
  const step = await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
392
- step_type: "tool_call",
440
+ step_type: 'tool_call',
393
441
  target_type: target.target_type,
394
442
  target_id: target.target_id,
395
443
  content: {
396
444
  call_id: runtimeEvent.callId,
397
445
  tool_name: runtimeEvent.toolName,
398
446
  tool_input: runtimeEvent.input,
399
- status: "running",
447
+ status: 'running',
400
448
  started_at: runtimeEvent.startedAt ?? new Date().toISOString(),
401
449
  },
402
450
  group_key: runtimeEvent.groupKey,
@@ -410,15 +458,15 @@ export class ParallAgentGateway {
410
458
  }
411
459
  break;
412
460
  }
413
- case "tool_result":
461
+ case 'tool_result':
414
462
  await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
415
- step_type: "tool_result",
463
+ step_type: 'tool_result',
416
464
  target_type: target.target_type,
417
465
  target_id: target.target_id,
418
466
  content: {
419
467
  call_id: runtimeEvent.callId,
420
468
  tool_name: runtimeEvent.toolName,
421
- status: runtimeEvent.error ? "error" : "success",
469
+ status: runtimeEvent.error ? 'error' : 'success',
422
470
  output: runtimeEvent.output,
423
471
  duration_ms: runtimeEvent.durationMs ?? 0,
424
472
  collapsible: true,
@@ -432,9 +480,9 @@ export class ParallAgentGateway {
432
480
  this.clearStepIdFile(stepIdFilePath);
433
481
  }
434
482
  break;
435
- case "error":
483
+ case 'error':
436
484
  await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, sessionId, {
437
- step_type: "text",
485
+ step_type: 'text',
438
486
  target_type: target.target_type,
439
487
  target_id: target.target_id,
440
488
  content: { text: runtimeEvent.message, suppressed: false },
@@ -444,13 +492,15 @@ export class ParallAgentGateway {
444
492
  }
445
493
  }
446
494
  catch (err) {
495
+ if (this.isSessionNotLiveError(err))
496
+ throw err;
447
497
  this.opts.log?.warn(`failed to create ${runtimeEvent.type} step: ${String(err)}`);
448
498
  }
449
499
  }
450
500
  writeContextFile(filePath, ctx) {
451
501
  try {
452
502
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
453
- fs.writeFileSync(filePath, JSON.stringify(ctx), "utf8");
503
+ fs.writeFileSync(filePath, JSON.stringify(ctx), 'utf8');
454
504
  }
455
505
  catch (err) {
456
506
  this.opts.log?.warn(`failed to write context file ${filePath}: ${String(err)}`);
@@ -458,10 +508,10 @@ export class ParallAgentGateway {
458
508
  }
459
509
  updateContextFileStepId(filePath, stepId) {
460
510
  try {
461
- const raw = fs.readFileSync(filePath, "utf8");
511
+ const raw = fs.readFileSync(filePath, 'utf8');
462
512
  const ctx = JSON.parse(raw);
463
513
  ctx.step_id = stepId;
464
- fs.writeFileSync(filePath, JSON.stringify(ctx), "utf8");
514
+ fs.writeFileSync(filePath, JSON.stringify(ctx), 'utf8');
465
515
  }
466
516
  catch (err) {
467
517
  this.opts.log?.warn(`failed to update context file step_id ${filePath}: ${String(err)}`);
@@ -469,10 +519,10 @@ export class ParallAgentGateway {
469
519
  }
470
520
  updateContextFileSessionId(filePath, sessionId) {
471
521
  try {
472
- const raw = fs.readFileSync(filePath, "utf8");
522
+ const raw = fs.readFileSync(filePath, 'utf8');
473
523
  const ctx = JSON.parse(raw);
474
524
  ctx.session_id = sessionId;
475
- fs.writeFileSync(filePath, JSON.stringify(ctx), "utf8");
525
+ fs.writeFileSync(filePath, JSON.stringify(ctx), 'utf8');
476
526
  }
477
527
  catch (err) {
478
528
  this.opts.log?.warn(`failed to update context file session_id ${filePath}: ${String(err)}`);
@@ -482,7 +532,7 @@ export class ParallAgentGateway {
482
532
  writeStepIdFile(filePath, stepId) {
483
533
  try {
484
534
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
485
- fs.writeFileSync(filePath, stepId, "utf8");
535
+ fs.writeFileSync(filePath, stepId, 'utf8');
486
536
  }
487
537
  catch (err) {
488
538
  this.opts.log?.warn(`failed to write step id file ${filePath}: ${String(err)}`);
@@ -491,7 +541,7 @@ export class ParallAgentGateway {
491
541
  /** @deprecated Use writeContextFile / updateContextFileStepId. */
492
542
  clearStepIdFile(filePath) {
493
543
  try {
494
- fs.writeFileSync(filePath, "", "utf8");
544
+ fs.writeFileSync(filePath, '', 'utf8');
495
545
  }
496
546
  catch {
497
547
  // Best-effort cleanup.
@@ -567,118 +617,237 @@ export class ParallAgentGateway {
567
617
  return false;
568
618
  }
569
619
  if (this.pendingRestartNotification) {
570
- bodyForAgent = this.pendingRestartNotification + "\n\n---\n\n" + bodyForAgent;
620
+ bodyForAgent = this.pendingRestartNotification + '\n\n---\n\n' + bodyForAgent;
571
621
  this.pendingRestartNotification = null;
572
622
  }
573
- setSessionChatId(sessionKey, event.targetId);
574
- setSessionMessageId(sessionKey, event.messageId);
575
- setDispatchMessageId(sessionKey, event.messageId);
576
- setDispatchNoReply(sessionKey, event.noReply ?? false);
577
- const dispatchContext = this.buildDispatchContext(event, sessionKey);
578
- const contextFilePath = dispatchContext.contextFilePath;
579
- const stepIdFilePath = dispatchContext.stepIdFilePath;
580
- if (contextFilePath) {
581
- this.writeContextFile(contextFilePath, {
582
- session_id: dispatchContext.sessionId ?? null,
583
- chat_id: dispatchContext.chatId ?? null,
584
- trigger_message_id: dispatchContext.triggerMessageId ?? null,
585
- no_reply: dispatchContext.noReply,
586
- step_id: null,
587
- });
588
- }
589
- // sync: no await between the shuttingDown check above and this increment
590
- // — JS event loop is single-threaded, so shutdown() cannot interleave
591
- // here and miss our in-flight count.
592
- this.inFlightDispatches++;
593
- let binding = this.sessionBindings.get(sessionKey);
594
- let inputStepsCreated = false;
595
- let triggerMessageSet = false;
596
- try {
597
- for await (const runtimeEvent of this.opts.dispatchAdapter.dispatch({
598
- event,
599
- earlierEvents,
600
- bodyForAgent,
601
- sessionKey,
602
- context: dispatchContext,
603
- })) {
604
- if (runtimeEvent.type === "runtime_session") {
605
- 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
+ }
606
696
  if (!inputStepsCreated) {
607
- // Persist input steps for "earlier events" (batched events that arrived
608
- // while a dispatch was in flight) inside the in-flight window so a
609
- // shutdown short-circuit BEFORE this point cannot leave orphan input
610
- // steps that the replacement pod would duplicate on replay.
611
697
  if (earlierEvents.length > 0) {
612
698
  await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
613
699
  }
614
700
  await this.createInputStep(binding.agentSessionId, event);
615
701
  inputStepsCreated = true;
616
702
  }
617
- 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);
618
724
  }
619
725
  if (!binding) {
620
- const detail = runtimeEvent.type === "error" ? `: ${runtimeEvent.message}` : "";
621
- throw new Error(`runtime emitted ${runtimeEvent.type} before runtime_session${detail}`);
726
+ binding = this.sessionBindings.get(sessionKey);
622
727
  }
623
- if (!triggerMessageSet) {
624
- triggerMessageSet = true;
625
- 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');
626
730
  }
627
731
  if (!inputStepsCreated) {
628
732
  if (earlierEvents.length > 0) {
629
733
  await this.createInputStepsForEarlierEvents(binding.agentSessionId, earlierEvents);
630
734
  }
631
735
  await this.createInputStep(binding.agentSessionId, event);
632
- inputStepsCreated = true;
633
736
  }
634
- if (captureText && runtimeEvent.type === "text" && runtimeEvent.text) {
635
- captureText.push(runtimeEvent.text);
636
- }
637
- await this.createRuntimeStep(binding.agentSessionId, event, runtimeEvent, stepIdFilePath, contextFilePath);
638
- }
639
- if (!binding) {
640
- binding = this.sessionBindings.get(sessionKey);
641
- }
642
- if (!binding) {
643
- throw new Error("runtime completed without runtime_session");
644
737
  }
645
- if (!inputStepsCreated) {
646
- if (earlierEvents.length > 0) {
647
- 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
+ }
648
752
  }
649
- await this.createInputStep(binding.agentSessionId, event);
650
- }
651
- }
652
- catch (err) {
653
- if (binding) {
654
- await this.createRuntimeStep(binding.agentSessionId, event, {
655
- type: "error",
656
- message: `Dispatch failed: ${String(err)}`,
657
- }, stepIdFilePath, contextFilePath);
658
- }
659
- throw err;
660
- }
661
- finally {
662
- if (triggerMessageSet && binding) {
663
- 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}`));
664
- }
665
- clearSessionMessageId(sessionKey);
666
- clearDispatchMessageId(sessionKey);
667
- clearDispatchNoReply(sessionKey);
668
- if (contextFilePath) {
669
- this.updateContextFileStepId(contextFilePath, null);
670
- }
671
- else if (stepIdFilePath) {
672
- 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;
673
769
  }
674
- this.inFlightDispatches--;
675
- if (this.inFlightDispatches === 0 && this.drainResolvers.length > 0) {
676
- const resolvers = this.drainResolvers.splice(0);
677
- for (const resolve of resolvers)
678
- 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
+ }
679
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
+ });
680
850
  }
681
- return true;
682
851
  }
683
852
  async runForkDrainLoop(fork) {
684
853
  let lastCapturedText = [];
@@ -690,7 +859,7 @@ export class ParallAgentGateway {
690
859
  // shuttingDown gate and inFlightDispatches counter, so a shutdown
691
860
  // landing mid-batch cannot leave orphan steps that replay would
692
861
  // duplicate.
693
- if (this.shuttingDown) {
862
+ if (this.shuttingDown || fork.deadlineExceeded) {
694
863
  for (const item of fork.queue.splice(0))
695
864
  item.resolve(false);
696
865
  break;
@@ -701,7 +870,7 @@ export class ParallAgentGateway {
701
870
  const earlier = events.slice(0, -1);
702
871
  try {
703
872
  const batchText = [];
704
- 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);
705
874
  if (!dispatched) {
706
875
  // Shutdown short-circuit — resolve un-acked so the server requeues
707
876
  // for the replacement pod and stop draining further items.
@@ -712,6 +881,11 @@ export class ParallAgentGateway {
712
881
  }
713
882
  if (batchText.length > 0)
714
883
  lastCapturedText = batchText;
884
+ if (fork.deadlineExceeded) {
885
+ for (const item of items)
886
+ item.resolve(false);
887
+ break;
888
+ }
715
889
  fork.processedEvents.push(...events);
716
890
  for (const item of items) {
717
891
  item.resolve(true);
@@ -730,9 +904,13 @@ export class ParallAgentGateway {
730
904
  }
731
905
  }
732
906
  finally {
907
+ if (fork.deadlineTimer) {
908
+ clearTimeout(fork.deadlineTimer);
909
+ fork.deadlineTimer = null;
910
+ }
733
911
  if (fork.processedEvents.length > 0) {
734
912
  const first = fork.processedEvents[0];
735
- const agentSummary = lastCapturedText.join("").trim() || undefined;
913
+ const agentSummary = lastCapturedText.join('').trim() || undefined;
736
914
  let historyPath;
737
915
  try {
738
916
  historyPath = this.opts.dispatchAdapter.getSessionHistoryPath?.(fork.fork.sessionKey);
@@ -755,8 +933,12 @@ export class ParallAgentGateway {
755
933
  historyPath,
756
934
  });
757
935
  }
758
- this.forkStates.delete(fork.targetId);
759
- 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
+ }
760
942
  const forkBinding = this.sessionBindings.get(fork.fork.sessionKey);
761
943
  if (this.opts.dispatchAdapter.cleanupFork) {
762
944
  const cleanupOpts = {
@@ -775,10 +957,17 @@ export class ParallAgentGateway {
775
957
  log: this.opts.log,
776
958
  },
777
959
  };
778
- 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
+ }
779
966
  }
780
967
  if (forkBinding) {
781
- 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(() => { });
782
971
  this.sessionBindings.delete(fork.fork.sessionKey);
783
972
  }
784
973
  }
@@ -805,13 +994,22 @@ export class ParallAgentGateway {
805
994
  const event = events[events.length - 1];
806
995
  const earlier = events.slice(0, -1);
807
996
  const hasPendingInjections = this.opts.dispatchAdapter.hasPendingInjections?.(this.opts.runtimeKey) ?? false;
808
- const injectedTypingCount = hasPendingInjections ? this.takeInjectedTypingCount(event.targetId) : 0;
809
- const pendingFork = hasPendingInjections ? [] : this.dispatchState.pendingForkResults.splice(0);
997
+ const pendingFork = hasPendingInjections
998
+ ? []
999
+ : this.dispatchState.pendingForkResults.splice(0);
810
1000
  const forkPrefix = buildForkResultPrefix(pendingFork);
811
1001
  this.dispatchState.mainCurrentTargetId = event.targetId;
812
- this.dispatchState.mainPreDispatchBranchPoint =
813
- this.opts.dispatchAdapter.getBranchPoint?.(this.opts.runtimeKey);
814
- 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);
815
1013
  if (!dispatched) {
816
1014
  // Shutdown: skip the ack so the server redelivers these buffered
817
1015
  // events to the replacement pod via dispatch catch-up. Put both the
@@ -821,12 +1019,15 @@ export class ParallAgentGateway {
821
1019
  break;
822
1020
  }
823
1021
  for (const bufferedEvent of events) {
824
- const sourceType = bufferedEvent.ackSourceType ?? (bufferedEvent.type === "task" ? "task_activity" : "message");
1022
+ const sourceType = bufferedEvent.ackSourceType ??
1023
+ (bufferedEvent.type === 'task' ? 'task_activity' : 'message');
825
1024
  const sourceId = bufferedEvent.ackSourceId ?? bufferedEvent.messageId;
826
- this.opts.client.ackDispatch(this.opts.config.org_id, {
1025
+ this.opts.client
1026
+ .ackDispatch(this.opts.config.org_id, {
827
1027
  source_type: sourceType,
828
1028
  source_id: sourceId,
829
- }).catch(() => { });
1029
+ })
1030
+ .catch(() => { });
830
1031
  }
831
1032
  }
832
1033
  }
@@ -835,12 +1036,22 @@ export class ParallAgentGateway {
835
1036
  this.dispatchState.mainDispatching = false;
836
1037
  this.dispatchState.mainCurrentTargetId = undefined;
837
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
+ }
838
1049
  }
839
1050
  }
840
1051
  async handleInboundEvent(event) {
841
1052
  const disposition = routeTrigger(event, this.dispatchState);
842
1053
  switch (disposition.action) {
843
- case "main": {
1054
+ case 'main': {
844
1055
  const pendingFork = this.dispatchState.pendingForkResults.splice(0);
845
1056
  const forkPrefix = buildForkResultPrefix(pendingFork);
846
1057
  this.dispatchState.mainDispatching = true;
@@ -848,11 +1059,21 @@ export class ParallAgentGateway {
848
1059
  // Snapshot the on-disk branch point BEFORE runDispatch starts writing
849
1060
  // to the session file. Fork sessions created while main is in-flight
850
1061
  // use this to branch from the clean pre-dispatch state.
851
- this.dispatchState.mainPreDispatchBranchPoint =
852
- 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
+ }
853
1074
  let dispatched = false;
854
1075
  try {
855
- dispatched = await this.runDispatchWithTyping(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event));
1076
+ dispatched = await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event));
856
1077
  if (!dispatched) {
857
1078
  // Shutdown short-circuit — restore the fork results so a future
858
1079
  // pod can replay them, and return false so handleMessage skips ack.
@@ -864,7 +1085,7 @@ export class ParallAgentGateway {
864
1085
  }
865
1086
  return dispatched;
866
1087
  }
867
- case "buffer-main": {
1088
+ case 'buffer-main': {
868
1089
  if (this.shuttingDown) {
869
1090
  return false;
870
1091
  }
@@ -873,20 +1094,21 @@ export class ParallAgentGateway {
873
1094
  // gap between the steer await and the push.
874
1095
  this.dispatchState.mainBuffer.push(event);
875
1096
  if (this.dispatchState.mainCurrentTargetId === event.targetId &&
876
- await this.opts.dispatchAdapter.enqueueDuringDispatch?.(this.opts.runtimeKey, buildEventBody(event))) {
877
- this.startInjectedTyping(event);
1097
+ (await this.opts.dispatchAdapter.enqueueDuringDispatch?.(this.opts.runtimeKey, buildEventBody(event)))) {
878
1098
  this.opts.log?.info(`steer injected for ${event.messageId} (will drain for bookkeeping)`);
879
1099
  }
880
1100
  // If the main dispatch cycle ended while we awaited the steer RPC,
881
1101
  // our event is buffered but no drain is in flight. Re-enter the
882
1102
  // drain to process it. The draining guard prevents re-entry.
883
- 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) {
884
1106
  this.dispatchState.mainDispatching = true;
885
1107
  void this.drainMainBuffer();
886
1108
  }
887
1109
  return false;
888
1110
  }
889
- case "buffer-fork": {
1111
+ case 'buffer-fork': {
890
1112
  const activeFork = this.forkStates.get(event.targetId);
891
1113
  if (!activeFork) {
892
1114
  this.dispatchState.mainBuffer.push(event);
@@ -896,11 +1118,19 @@ export class ParallAgentGateway {
896
1118
  activeFork.queue.push({ event, resolve });
897
1119
  });
898
1120
  }
899
- case "new-fork": {
1121
+ case 'new-fork': {
900
1122
  if (!this.opts.dispatchAdapter.forkSession) {
901
1123
  this.dispatchState.mainBuffer.push(event);
902
1124
  return false;
903
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
+ }
904
1134
  const fork = await this.opts.dispatchAdapter.forkSession({
905
1135
  sessionKey: this.opts.runtimeKey,
906
1136
  context: this.buildDispatchContext(event, this.opts.runtimeKey),
@@ -916,9 +1146,14 @@ export class ParallAgentGateway {
916
1146
  targetId: event.targetId,
917
1147
  queue: [],
918
1148
  processedEvents: [],
1149
+ deadlineTimer: null,
1150
+ deadlineExceeded: false,
919
1151
  };
920
1152
  this.forkStates.set(event.targetId, activeFork);
921
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);
922
1157
  const firstEventPromise = new Promise((resolve) => {
923
1158
  activeFork.queue.push({ event, resolve });
924
1159
  });
@@ -949,22 +1184,22 @@ export class ParallAgentGateway {
949
1184
  }
950
1185
  }
951
1186
  async buildMessageDispatchDecision(chatId, message) {
952
- if (message.message_type !== "text") {
953
- return { action: "skip" };
1187
+ if (message.message_type !== 'text') {
1188
+ return { action: 'skip' };
954
1189
  }
955
1190
  const chatInfo = await this.getOrFetchChatInfo(chatId);
956
1191
  if (!chatInfo)
957
- return { action: "retry" };
1192
+ return { action: 'retry' };
958
1193
  const content = message.content;
959
- const body = content.text?.trim() ?? "";
1194
+ const body = content.text?.trim() ?? '';
960
1195
  const hasAttachments = message.attachments?.length;
961
1196
  if (!body && !hasAttachments)
962
- return { action: "skip" };
963
- if (chatInfo.type === "group" && chatInfo.agentRoutingMode !== "active") {
1197
+ return { action: 'skip' };
1198
+ if (chatInfo.type === 'group' && chatInfo.agentRoutingMode !== 'active') {
964
1199
  const mentions = content.mentions ?? [];
965
1200
  const isMentioned = mentions.some((mention) => mention.user_id === this.opts.agentUserId || mention.user_id === MENTION_ALL_USER_ID);
966
1201
  if (!isMentioned)
967
- return { action: "skip" };
1202
+ return { action: 'skip' };
968
1203
  }
969
1204
  const attachments = (message.attachments ?? []).map((a) => ({
970
1205
  id: a.id,
@@ -972,23 +1207,56 @@ export class ParallAgentGateway {
972
1207
  fileSize: a.file_size,
973
1208
  mimeType: a.mime_type,
974
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
+ }
975
1238
  return {
976
- action: "dispatch",
1239
+ action: 'dispatch',
977
1240
  event: {
978
- type: "message",
1241
+ type: 'message',
979
1242
  targetId: chatId,
980
1243
  targetName: chatInfo.name ?? undefined,
981
1244
  targetType: chatInfo.type,
982
1245
  senderId: message.sender_id,
983
1246
  senderName: message.sender?.display_name ?? message.sender_id,
984
1247
  messageId: message.id,
985
- body: body || "[attachment]",
1248
+ body: body || '[attachment]',
986
1249
  threadRootId: message.thread_root_id ?? undefined,
987
1250
  noReply: message.hints?.no_reply ?? false,
988
1251
  attachments: attachments.length > 0 ? attachments : undefined,
989
1252
  sentAt: message.created_at,
990
- ackSourceType: "message",
1253
+ ackSourceType: 'message',
991
1254
  ackSourceId: message.id,
1255
+ unreadCount,
1256
+ unreadSince,
1257
+ threadReplyCount,
1258
+ threadUnreadCount,
1259
+ threadUnreadSince,
992
1260
  },
993
1261
  };
994
1262
  }
@@ -998,12 +1266,12 @@ export class ParallAgentGateway {
998
1266
  const chatId = data.chat_id;
999
1267
  if (data.sender_id === this.opts.agentUserId)
1000
1268
  return;
1001
- if (data.message_type !== "text")
1269
+ if (data.message_type !== 'text')
1002
1270
  return;
1003
1271
  if (!this.tryClaimMessage(data.id))
1004
1272
  return;
1005
1273
  const decision = await this.buildMessageDispatchDecision(chatId, data);
1006
- if (decision.action !== "dispatch") {
1274
+ if (decision.action !== 'dispatch') {
1007
1275
  this.dispatchedMessages.delete(data.id);
1008
1276
  return;
1009
1277
  }
@@ -1011,7 +1279,9 @@ export class ParallAgentGateway {
1011
1279
  try {
1012
1280
  const dispatched = await this.handleInboundEvent(event);
1013
1281
  if (dispatched) {
1014
- 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(() => { });
1015
1285
  }
1016
1286
  else {
1017
1287
  this.dispatchedMessages.delete(data.id);
@@ -1039,18 +1309,18 @@ export class ParallAgentGateway {
1039
1309
  if (task.parent_id)
1040
1310
  parts.push(`Parent: prll://${task.parent_id}`);
1041
1311
  if (task.description)
1042
- parts.push("", task.description);
1312
+ parts.push('', task.description);
1043
1313
  const event = {
1044
- type: "task",
1314
+ type: 'task',
1045
1315
  targetId: task.id,
1046
1316
  targetName: task.identifier ?? undefined,
1047
- targetType: "task",
1317
+ targetType: 'task',
1048
1318
  senderId: task.creator_id,
1049
- senderName: "system",
1319
+ senderName: 'system',
1050
1320
  messageId: task.id,
1051
- body: parts.join("\n"),
1321
+ body: parts.join('\n'),
1052
1322
  sentAt: task.updated_at ?? task.created_at,
1053
- ackSourceType: "task_activity",
1323
+ ackSourceType: 'task_activity',
1054
1324
  ackSourceId,
1055
1325
  };
1056
1326
  const dispatched = await this.handleInboundEvent(event);
@@ -1113,9 +1383,11 @@ export class ParallAgentGateway {
1113
1383
  try {
1114
1384
  task = await this.opts.client.getTask(this.opts.config.org_id, taskId);
1115
1385
  }
1116
- catch { /* task context is optional */ }
1386
+ catch {
1387
+ /* task context is optional */
1388
+ }
1117
1389
  const taskLabel = task ? `${task.identifier ?? task.id} "${task.title}"` : taskId;
1118
- this.opts.log?.info(`task comment on ${taskLabel} by ${actorId ?? "unknown"}`);
1390
+ this.opts.log?.info(`task comment on ${taskLabel} by ${actorId ?? 'unknown'}`);
1119
1391
  const parts = [];
1120
1392
  if (task) {
1121
1393
  parts.push(`Task: ${task.title} (prll://${task.id})`);
@@ -1124,19 +1396,87 @@ export class ParallAgentGateway {
1124
1396
  else {
1125
1397
  parts.push(`Task: prll://${taskId}`);
1126
1398
  }
1127
- parts.push(`Comment by: ${comment.author?.display_name ?? actorId ?? "unknown"} (prll://${comment.author_id})`);
1128
- parts.push("", comment.body);
1399
+ parts.push(`Comment by: ${comment.author?.display_name ?? actorId ?? 'unknown'} (prll://${comment.author_id})`);
1400
+ parts.push('', comment.body);
1129
1401
  const event = {
1130
- type: "task_comment",
1402
+ type: 'task_comment',
1131
1403
  targetId: taskId,
1132
1404
  targetName: task?.identifier ?? undefined,
1133
- targetType: "task",
1405
+ targetType: 'task',
1134
1406
  senderId: comment.author_id,
1135
- senderName: comment.author?.display_name ?? actorId ?? "unknown",
1407
+ senderName: comment.author?.display_name ?? actorId ?? 'unknown',
1136
1408
  messageId: commentId,
1137
- body: parts.join("\n"),
1409
+ body: parts.join('\n'),
1138
1410
  deliveryReason: deliveryReason ?? undefined,
1139
- 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',
1140
1480
  ackSourceId: commentId,
1141
1481
  };
1142
1482
  let dispatched;
@@ -1193,20 +1533,20 @@ export class ParallAgentGateway {
1193
1533
  this.dispatchedTasks.add(dedupeKey);
1194
1534
  this.opts.log?.info(`schedule fired: ${run.id} (schedule ${run.schedule_id})`);
1195
1535
  const event = {
1196
- type: "schedule",
1536
+ type: 'schedule',
1197
1537
  // Route by schedule_id (not attached chat_id) so concurrent fires of
1198
1538
  // different schedules can fork independently — matches the PR1 primitive
1199
1539
  // design where "schedule triggers; target decides response" and fire
1200
1540
  // semantics are independent of any attached conversation.
1201
1541
  targetId: run.schedule_id,
1202
- targetType: "schedule",
1203
- senderId: actorId ?? "system",
1204
- senderName: "schedule",
1542
+ targetType: 'schedule',
1543
+ senderId: actorId ?? 'system',
1544
+ senderName: 'schedule',
1205
1545
  messageId: run.id,
1206
- body: run.fired_description ?? "",
1546
+ body: run.fired_description ?? '',
1207
1547
  scheduledFireAt: run.scheduled_fire_at,
1208
1548
  attachedUri: run.fired_attached_uri ?? undefined,
1209
- ackSourceType: "schedule_run",
1549
+ ackSourceType: 'schedule_run',
1210
1550
  ackSourceId: run.id,
1211
1551
  };
1212
1552
  let dispatched;
@@ -1245,14 +1585,14 @@ export class ParallAgentGateway {
1245
1585
  return false;
1246
1586
  this.dispatchedTasks.add(dedupeKey);
1247
1587
  this.opts.log?.info(`approval decided: ${approval.id} (${approval.status})`);
1248
- const statusLabel = approval.status === "approved" ? "Approved" : "Rejected";
1249
- 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}` : '';
1250
1590
  const body = `${statusLabel}: ${approval.title}${execInfo}`;
1251
1591
  const event = {
1252
- type: "approval",
1592
+ type: 'approval',
1253
1593
  targetId: chatId ?? approval.chat_id,
1254
- senderId: actorId ?? approval.decided_by ?? "system",
1255
- senderName: "approver",
1594
+ senderId: actorId ?? approval.decided_by ?? 'system',
1595
+ senderName: 'approver',
1256
1596
  messageId: approval.id,
1257
1597
  body,
1258
1598
  };
@@ -1269,11 +1609,9 @@ export class ParallAgentGateway {
1269
1609
  }
1270
1610
  return dispatched;
1271
1611
  }
1272
- async catchUpFromDispatch(coldStart = false) {
1273
- const minAge = coldStart ? Date.now() - this.COLD_START_WINDOW_MS : 0;
1612
+ async catchUpFromDispatch() {
1274
1613
  let cursor;
1275
1614
  let processed = 0;
1276
- let skippedOld = 0;
1277
1615
  do {
1278
1616
  const page = await this.opts.client.getDispatch(this.opts.config.org_id, {
1279
1617
  limit: 50,
@@ -1284,15 +1622,10 @@ export class ParallAgentGateway {
1284
1622
  // will only reject. Items remain unacked for the replacement pod.
1285
1623
  if (this.shuttingDown)
1286
1624
  break;
1287
- if (minAge > 0 && new Date(item.created_at).getTime() < minAge) {
1288
- this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
1289
- skippedOld++;
1290
- continue;
1291
- }
1292
1625
  processed++;
1293
1626
  try {
1294
1627
  let dispatched = false;
1295
- if (item.event_type === "task_assign" && item.task_id) {
1628
+ if (item.event_type === 'task_assign' && item.task_id) {
1296
1629
  try {
1297
1630
  dispatched = await this.handleTaskDispatch(item.task_id, item.source_id ?? item.task_id);
1298
1631
  }
@@ -1301,7 +1634,7 @@ export class ParallAgentGateway {
1301
1634
  continue;
1302
1635
  }
1303
1636
  }
1304
- else if (item.event_type === "task_update" && item.task_id) {
1637
+ else if (item.event_type === 'task_update' && item.task_id) {
1305
1638
  try {
1306
1639
  dispatched = await this.handleTaskDispatch(item.task_id, item.source_id ?? item.task_id, { allowCreator: true });
1307
1640
  }
@@ -1310,16 +1643,19 @@ export class ParallAgentGateway {
1310
1643
  continue;
1311
1644
  }
1312
1645
  }
1313
- 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) {
1314
1647
  dispatched = await this.handleTaskComment(item.source_id, item.task_id, item.actor_id, item.delivery_reason);
1315
1648
  }
1316
- 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) {
1317
1653
  dispatched = await this.fetchAndHandleScheduleFire(item.source_id, item.actor_id);
1318
1654
  }
1319
- else if (item.event_type === "approval_decided" && item.source_id) {
1655
+ else if (item.event_type === 'approval_decided' && item.source_id) {
1320
1656
  dispatched = await this.fetchAndHandleApprovalDecided(item.source_id, item.actor_id, item.chat_id ?? null);
1321
1657
  }
1322
- 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) {
1323
1659
  if (!this.tryClaimMessage(item.source_id))
1324
1660
  continue;
1325
1661
  let msg = null;
@@ -1347,11 +1683,11 @@ export class ParallAgentGateway {
1347
1683
  continue;
1348
1684
  }
1349
1685
  const decision = await this.buildMessageDispatchDecision(item.chat_id, msg);
1350
- if (decision.action === "retry") {
1686
+ if (decision.action === 'retry') {
1351
1687
  this.dispatchedMessages.delete(item.source_id);
1352
1688
  continue;
1353
1689
  }
1354
- if (decision.action === "skip") {
1690
+ if (decision.action === 'skip') {
1355
1691
  this.dispatchedMessages.delete(item.source_id);
1356
1692
  this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
1357
1693
  continue;
@@ -1370,13 +1706,20 @@ export class ParallAgentGateway {
1370
1706
  // and the next page would be wasted API work the replacement pod redoes.
1371
1707
  cursor = !this.shuttingDown && page.has_more ? page.next_cursor : undefined;
1372
1708
  } while (cursor);
1373
- if (processed > 0 || skippedOld > 0) {
1374
- 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}`);
1375
1711
  }
1376
1712
  }
1377
1713
  async handleHello(data) {
1378
1714
  const { client, config, log } = this.opts;
1379
- 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
+ }
1380
1723
  const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
1381
1724
  try {
1382
1725
  const count = await fetchAllChats(client, config.org_id, this.chatInfoMap);
@@ -1397,7 +1740,7 @@ export class ParallAgentGateway {
1397
1740
  log?.warn(`heartbeat drift ${drift}ms — event loop may be blocked`);
1398
1741
  }
1399
1742
  this.lastHeartbeatAt = now;
1400
- if (this.opts.ws.state !== "connected")
1743
+ if (this.opts.ws.state !== 'connected')
1401
1744
  return;
1402
1745
  this.opts.ws.sendAgentHeartbeat(this.sessionId, {
1403
1746
  hostname: os.hostname(),
@@ -1407,9 +1750,7 @@ export class ParallAgentGateway {
1407
1750
  uptime: os.uptime(),
1408
1751
  });
1409
1752
  }, intervalSec * 1000);
1410
- const isFirstHello = !this.hadSuccessfulHello;
1411
- this.hadSuccessfulHello = true;
1412
- this.catchUpFromDispatch(isFirstHello).catch((err) => {
1753
+ this.catchUpFromDispatch().catch((err) => {
1413
1754
  log?.warn(`dispatch catch-up failed: ${String(err)}`);
1414
1755
  });
1415
1756
  }
@@ -1458,10 +1799,6 @@ export class ParallAgentGateway {
1458
1799
  }
1459
1800
  if (this.heartbeatTimer)
1460
1801
  clearInterval(this.heartbeatTimer);
1461
- for (const [, dispatch] of this.activeDispatches) {
1462
- clearInterval(dispatch.typingTimer);
1463
- }
1464
- this.activeDispatches.clear();
1465
1802
  await this.opts.onBeforeDisconnect?.();
1466
1803
  this.opts.ws.disconnect();
1467
1804
  this.opts.log?.info(`disconnected`);