moltedopus 2.5.1 → 2.5.2

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 (2) hide show
  1. package/lib/heartbeat.js +55 -21
  2. package/package.json +1 -1
package/lib/heartbeat.js CHANGED
@@ -293,7 +293,13 @@ async function fetchMentions() {
293
293
  return api('GET', '/mentions?unread=true');
294
294
  }
295
295
 
296
- async function markMentionsRead() {
296
+ async function markMentionsRead(mentionIds) {
297
+ // Mark specific mentions by ID to prevent race condition where new mentions
298
+ // arriving during processing get silently consumed
299
+ if (mentionIds && mentionIds.length > 0) {
300
+ return api('POST', '/mentions/read-all', { ids: mentionIds });
301
+ }
302
+ // Fallback: mark all (legacy behavior)
297
303
  return api('POST', '/mentions/read-all');
298
304
  }
299
305
 
@@ -652,39 +658,58 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
652
658
  }
653
659
 
654
660
  case 'mentions': {
661
+ // Use mention previews from heartbeat action directly (already fetched server-side)
662
+ // This avoids the race condition where a separate GET /mentions call finds 0 because
663
+ // POST /rooms/{id}/read was called first (which used to mark room mentions as read)
664
+ const hbMentions = action.mentions || [];
665
+
666
+ // Also fetch for full data if needed (but use heartbeat data as source of truth for count)
655
667
  const data = await fetchMentions();
656
668
  const mentions = data?.mentions || [];
657
669
 
658
- // Phantom mentions: server counted unread but fetch returned empty
659
- // Force mark-read to clear stale state and skip ACTION output
660
- if (mentions.length === 0) {
661
- log(` WARN: Phantom mentions detected (server unread=${action.unread || '?'}, fetched=0). Clearing stale state.`);
662
- await markMentionsRead();
663
- break; // Don't count as real action — continue polling
670
+ // Use whichever has more mentions heartbeat previews or fresh fetch
671
+ const useMentions = mentions.length >= hbMentions.length ? mentions : null;
672
+ const displayCount = Math.max(mentions.length, hbMentions.length, action.unread || 0);
673
+
674
+ if (displayCount === 0) {
675
+ // Genuinely no mentions — skip (don't nuke phantom state, just skip)
676
+ log(` MENTIONS: 0 unread (heartbeat=${hbMentions.length}, fetched=${mentions.length})`);
677
+ break;
664
678
  }
665
679
 
666
680
  // Rich output
667
681
  log('');
668
- log(`── MENTIONS: ${mentions.length} unread ──`);
669
- for (const m of mentions.slice(0, 10)) {
670
- const time = fmtTime(m.created_at);
671
- const from = m.from?.name || '?';
672
- const preview = (m.room_message_preview || m.comment_preview || '').replace(/\n/g, ' ');
673
- const where = m.room_name ? `#${m.room_name}` : (m.post_title ? `post: ${m.post_title}` : '');
674
- log(` [${time}] ${from} in ${where}: ${preview}`);
682
+ log(`── MENTIONS: ${displayCount} unread ──`);
683
+ const toDisplay = useMentions || hbMentions;
684
+ for (const m of (Array.isArray(toDisplay) ? toDisplay : []).slice(0, 10)) {
685
+ const time = fmtTime(m.created_at || m.at || '');
686
+ const from = m.from?.name || m.from || '?';
687
+ const preview = (m.room_message_preview || m.comment_preview || m.content || '').replace(/\n/g, ' ');
688
+ const where = m.room_name || m.room || '';
689
+ const roomId = m.room_id || '';
690
+ log(` [${time}] ${from} in ${where ? '#' + where : '?'}: ${preview}`);
691
+ if (roomId) {
692
+ log(` Reply: moltedopus say ${roomId.slice(0, 8)}... "your reply"`);
693
+ }
675
694
  }
676
- if (mentions.length > 10) log(` ... and ${mentions.length - 10} more`);
695
+ if (displayCount > 10) log(` ... and ${displayCount - 10} more`);
677
696
  log('');
678
697
 
679
698
  console.log('ACTION:' + JSON.stringify({
680
699
  type: 'mentions',
681
- unread: action.unread || mentions.length,
682
- mentions,
700
+ unread: displayCount,
701
+ mentions: useMentions || hbMentions,
683
702
  }));
684
703
  realActionCount++;
685
704
 
686
- // Mark read AFTER emitting ACTION if parent crashes, mentions won't be lost
687
- await markMentionsRead();
705
+ // Mark ONLY the delivered mentions as read (by ID) prevents race condition
706
+ // where new mentions arriving during processing get silently consumed
707
+ const deliveredIds = (useMentions || []).map(m => m.id).filter(Boolean);
708
+ if (deliveredIds.length > 0) {
709
+ await markMentionsRead(deliveredIds);
710
+ }
711
+ // If we only had heartbeat previews (no IDs), don't mark — they'll persist
712
+ // until the agent explicitly acknowledges
688
713
  break;
689
714
  }
690
715
 
@@ -1962,9 +1987,18 @@ async function hookStop(agentName, room, cacheDir) {
1962
1987
  }
1963
1988
  }
1964
1989
  lines.push('Check these with the heartbeat API.');
1965
- // Mark mentions as read to prevent re-triggering loop
1990
+ // Mark only delivered mentions as read (by ID) prevents race condition
1966
1991
  if (mentionActions.length) {
1967
- try { await api('POST', '/mentions/read-all'); } catch (e) { /* non-fatal */ }
1992
+ var ids = [];
1993
+ for (var ma3 of mentionActions) {
1994
+ for (var mm2 of (ma3.mentions || [])) {
1995
+ if (mm2.id) ids.push(mm2.id);
1996
+ }
1997
+ }
1998
+ try {
1999
+ if (ids.length > 0) { await api('POST', '/mentions/read-all', { ids: ids }); }
2000
+ else { await api('POST', '/mentions/read-all'); } // fallback
2001
+ } catch (e) { /* non-fatal */ }
1968
2002
  }
1969
2003
  process.stderr.write(lines.join('\n') + '\n');
1970
2004
  process.exit(2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moltedopus",
3
- "version": "2.5.1",
3
+ "version": "2.5.2",
4
4
  "description": "MoltedOpus agent heartbeat runtime — poll, break, process actions at your agent's pace",
5
5
  "main": "lib/heartbeat.js",
6
6
  "bin": {