@botcord/daemon 0.2.20 → 0.2.21

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.
@@ -225,7 +225,10 @@ export function createBotCordChannel(options) {
225
225
  const eligible = [];
226
226
  if (msgs.length === 0) {
227
227
  logDrain();
228
- return;
228
+ // Defensive: if Hub returns 0 messages, refuse to honor has_more=true.
229
+ // A stuck cursor on the Hub side could otherwise produce an unbounded
230
+ // poll loop here (count=0 with has_more=true on every iteration).
231
+ return { hasMore: false };
229
232
  }
230
233
  // First pass: ack duplicates/skipped messages so Hub stops requeueing,
231
234
  // and collect eligible messages preserving poll order. Grouping by
@@ -261,7 +264,7 @@ export function createBotCordChannel(options) {
261
264
  }
262
265
  if (eligible.length === 0) {
263
266
  logDrain();
264
- return;
267
+ return { hasMore: Boolean(resp.has_more) };
265
268
  }
266
269
  // Group by `(room_id, topic)`. Insertion order is the poll order, so
267
270
  // iterating the map yields groups with the same external chronology.
@@ -275,6 +278,13 @@ export function createBotCordChannel(options) {
275
278
  else
276
279
  groups.set(key, [msg]);
277
280
  }
281
+ // Emit groups in parallel: each `(room_id, topic)` group is an independent
282
+ // conversation thread, and the dispatcher already keys its per-turn queue
283
+ // by `(channel, accountId, roomId, threadId)` (see `buildQueueKey` in
284
+ // dispatcher.ts). Awaiting groups serially here forced a slow turn in
285
+ // room A to block room B's turn from starting; running them concurrently
286
+ // lets the dispatcher's per-room queues actually run in parallel.
287
+ const emitTasks = [];
278
288
  for (const group of groups.values()) {
279
289
  const normalized = normalizeInboxBatch(group, {
280
290
  channelId: options.id,
@@ -301,18 +311,18 @@ export function createBotCordChannel(options) {
301
311
  },
302
312
  },
303
313
  };
304
- try {
305
- await emit(envelope);
314
+ emitTasks.push(emit(envelope).then(() => {
306
315
  emittedGroups += 1;
307
- }
308
- catch (err) {
316
+ }, (err) => {
309
317
  log.error("botcord emit threw", {
310
318
  hubMsgIds: hubIds,
311
319
  err: String(err),
312
320
  });
313
- }
321
+ }));
314
322
  }
323
+ await Promise.all(emitTasks);
315
324
  logDrain();
325
+ return { hasMore: Boolean(resp.has_more) };
316
326
  }
317
327
  function startWsLoop(client, ctx) {
318
328
  const { abortSignal, log, emit, setStatus } = ctx;
@@ -354,11 +364,16 @@ export function createBotCordChannel(options) {
354
364
  processing = true;
355
365
  try {
356
366
  let currentTrigger = trigger;
367
+ let hasMore = false;
357
368
  do {
358
369
  pendingUpdate = false;
359
- await drainInbox(client, emit, log, currentTrigger);
360
- currentTrigger = "coalesced_inbox_update";
361
- } while (pendingUpdate && running);
370
+ const result = await drainInbox(client, emit, log, currentTrigger);
371
+ hasMore = result.hasMore;
372
+ // Prefer `has_more_continue` when this iteration is chained because
373
+ // the previous poll capped at INBOX_POLL_LIMIT — distinguishes a
374
+ // backlog drain from a coalesced ws_inbox_update drain in logs.
375
+ currentTrigger = hasMore ? "has_more_continue" : "coalesced_inbox_update";
376
+ } while ((pendingUpdate || hasMore) && running);
362
377
  }
363
378
  catch (err) {
364
379
  log.error("botcord inbox drain failed", { err: String(err) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,11 @@ const OWNER_CHAT_PREFIX = "rm_oc_";
30
30
  const DM_ROOM_PREFIX = "rm_dm_";
31
31
  const INBOX_POLL_LIMIT = 50;
32
32
 
33
- type InboxDrainTrigger = "ws_auth_ok" | "ws_inbox_update" | "coalesced_inbox_update";
33
+ type InboxDrainTrigger =
34
+ | "ws_auth_ok"
35
+ | "ws_inbox_update"
36
+ | "coalesced_inbox_update"
37
+ | "has_more_continue";
34
38
 
35
39
  /** Minimal surface the adapter needs from `BotCordClient`. Matches the subset used at runtime. */
36
40
  export interface BotCordChannelClient {
@@ -309,7 +313,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
309
313
  emit: (env: GatewayInboundEnvelope) => Promise<void>,
310
314
  log: GatewayLogger,
311
315
  trigger: InboxDrainTrigger,
312
- ): Promise<void> {
316
+ ): Promise<{ hasMore: boolean }> {
313
317
  const startedAt = Date.now();
314
318
  const resp = await client.pollInbox({ limit: INBOX_POLL_LIMIT, ack: false });
315
319
  const msgs = resp.messages ?? [];
@@ -334,7 +338,10 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
334
338
  const eligible: InboxMessage[] = [];
335
339
  if (msgs.length === 0) {
336
340
  logDrain();
337
- return;
341
+ // Defensive: if Hub returns 0 messages, refuse to honor has_more=true.
342
+ // A stuck cursor on the Hub side could otherwise produce an unbounded
343
+ // poll loop here (count=0 with has_more=true on every iteration).
344
+ return { hasMore: false };
338
345
  }
339
346
 
340
347
  // First pass: ack duplicates/skipped messages so Hub stops requeueing,
@@ -370,7 +377,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
370
377
 
371
378
  if (eligible.length === 0) {
372
379
  logDrain();
373
- return;
380
+ return { hasMore: Boolean(resp.has_more) };
374
381
  }
375
382
 
376
383
  // Group by `(room_id, topic)`. Insertion order is the poll order, so
@@ -384,6 +391,13 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
384
391
  else groups.set(key, [msg]);
385
392
  }
386
393
 
394
+ // Emit groups in parallel: each `(room_id, topic)` group is an independent
395
+ // conversation thread, and the dispatcher already keys its per-turn queue
396
+ // by `(channel, accountId, roomId, threadId)` (see `buildQueueKey` in
397
+ // dispatcher.ts). Awaiting groups serially here forced a slow turn in
398
+ // room A to block room B's turn from starting; running them concurrently
399
+ // lets the dispatcher's per-room queues actually run in parallel.
400
+ const emitTasks: Promise<void>[] = [];
387
401
  for (const group of groups.values()) {
388
402
  const normalized = normalizeInboxBatch(group, {
389
403
  channelId: options.id,
@@ -409,17 +423,23 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
409
423
  },
410
424
  },
411
425
  };
412
- try {
413
- await emit(envelope);
414
- emittedGroups += 1;
415
- } catch (err) {
416
- log.error("botcord emit threw", {
417
- hubMsgIds: hubIds,
418
- err: String(err),
419
- });
420
- }
426
+ emitTasks.push(
427
+ emit(envelope).then(
428
+ () => {
429
+ emittedGroups += 1;
430
+ },
431
+ (err) => {
432
+ log.error("botcord emit threw", {
433
+ hubMsgIds: hubIds,
434
+ err: String(err),
435
+ });
436
+ },
437
+ ),
438
+ );
421
439
  }
440
+ await Promise.all(emitTasks);
422
441
  logDrain();
442
+ return { hasMore: Boolean(resp.has_more) };
423
443
  }
424
444
 
425
445
  function startWsLoop(
@@ -470,11 +490,16 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
470
490
  processing = true;
471
491
  try {
472
492
  let currentTrigger = trigger;
493
+ let hasMore = false;
473
494
  do {
474
495
  pendingUpdate = false;
475
- await drainInbox(client, emit, log, currentTrigger);
476
- currentTrigger = "coalesced_inbox_update";
477
- } while (pendingUpdate && running);
496
+ const result = await drainInbox(client, emit, log, currentTrigger);
497
+ hasMore = result.hasMore;
498
+ // Prefer `has_more_continue` when this iteration is chained because
499
+ // the previous poll capped at INBOX_POLL_LIMIT — distinguishes a
500
+ // backlog drain from a coalesced ws_inbox_update drain in logs.
501
+ currentTrigger = hasMore ? "has_more_continue" : "coalesced_inbox_update";
502
+ } while ((pendingUpdate || hasMore) && running);
478
503
  } catch (err) {
479
504
  log.error("botcord inbox drain failed", { err: String(err) });
480
505
  } finally {