crewly 1.20.203 → 1.20.204

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 (139) hide show
  1. package/config/skills/agent/core/reply/SKILL.md +1 -0
  2. package/config/skills/agent/core/reply/execute.sh +7 -1
  3. package/config/skills/agent/core/reply-channel/SKILL.md +4 -0
  4. package/config/skills/agent/core/reply-channel/execute.sh +12 -1
  5. package/dist/backend/backend/src/constants.d.ts +60 -0
  6. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  7. package/dist/backend/backend/src/constants.js +60 -0
  8. package/dist/backend/backend/src/constants.js.map +1 -1
  9. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts.map +1 -1
  10. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js +1 -0
  11. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js.map +1 -1
  12. package/dist/backend/backend/src/controllers/chat/chat.controller.d.ts.map +1 -1
  13. package/dist/backend/backend/src/controllers/chat/chat.controller.js +1 -0
  14. package/dist/backend/backend/src/controllers/chat/chat.controller.js.map +1 -1
  15. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.d.ts.map +1 -1
  16. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js +50 -0
  17. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js.map +1 -1
  18. package/dist/backend/backend/src/controllers/monitoring/terminal.controller.d.ts +6 -0
  19. package/dist/backend/backend/src/controllers/monitoring/terminal.controller.d.ts.map +1 -1
  20. package/dist/backend/backend/src/controllers/monitoring/terminal.controller.js +2 -1
  21. package/dist/backend/backend/src/controllers/monitoring/terminal.controller.js.map +1 -1
  22. package/dist/backend/backend/src/index.d.ts.map +1 -1
  23. package/dist/backend/backend/src/index.js +9 -1
  24. package/dist/backend/backend/src/index.js.map +1 -1
  25. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts +117 -0
  26. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
  27. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +165 -9
  28. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
  29. package/dist/backend/backend/src/services/chat-v2/room-context-backlog.d.ts +93 -0
  30. package/dist/backend/backend/src/services/chat-v2/room-context-backlog.d.ts.map +1 -0
  31. package/dist/backend/backend/src/services/chat-v2/room-context-backlog.js +138 -0
  32. package/dist/backend/backend/src/services/chat-v2/room-context-backlog.js.map +1 -0
  33. package/dist/backend/backend/src/services/decisions/decision-card.d.ts.map +1 -1
  34. package/dist/backend/backend/src/services/decisions/decision-card.js +5 -1
  35. package/dist/backend/backend/src/services/decisions/decision-card.js.map +1 -1
  36. package/dist/backend/backend/src/services/decisions/decision.service.d.ts +26 -1
  37. package/dist/backend/backend/src/services/decisions/decision.service.d.ts.map +1 -1
  38. package/dist/backend/backend/src/services/decisions/decision.service.js +83 -5
  39. package/dist/backend/backend/src/services/decisions/decision.service.js.map +1 -1
  40. package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.d.ts +21 -0
  41. package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.d.ts.map +1 -1
  42. package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.js +48 -3
  43. package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.js.map +1 -1
  44. package/dist/backend/backend/src/services/monitoring/liveness-monitor.service.d.ts +27 -1
  45. package/dist/backend/backend/src/services/monitoring/liveness-monitor.service.d.ts.map +1 -1
  46. package/dist/backend/backend/src/services/monitoring/liveness-monitor.service.js +51 -2
  47. package/dist/backend/backend/src/services/monitoring/liveness-monitor.service.js.map +1 -1
  48. package/dist/backend/backend/src/services/orc/orc-chat-owner-mirror.d.ts +51 -4
  49. package/dist/backend/backend/src/services/orc/orc-chat-owner-mirror.d.ts.map +1 -1
  50. package/dist/backend/backend/src/services/orc/orc-chat-owner-mirror.js +83 -6
  51. package/dist/backend/backend/src/services/orc/orc-chat-owner-mirror.js.map +1 -1
  52. package/dist/backend/backend/src/services/orc/reply-destination.wiring.d.ts +16 -0
  53. package/dist/backend/backend/src/services/orc/reply-destination.wiring.d.ts.map +1 -1
  54. package/dist/backend/backend/src/services/orc/reply-destination.wiring.js +22 -2
  55. package/dist/backend/backend/src/services/orc/reply-destination.wiring.js.map +1 -1
  56. package/dist/backend/backend/src/services/restart/in-flight-turn-tracker.service.d.ts +9 -0
  57. package/dist/backend/backend/src/services/restart/in-flight-turn-tracker.service.d.ts.map +1 -1
  58. package/dist/backend/backend/src/services/restart/in-flight-turn-tracker.service.js +11 -0
  59. package/dist/backend/backend/src/services/restart/in-flight-turn-tracker.service.js.map +1 -1
  60. package/dist/backend/backend/src/services/slack/room-responder.d.ts +144 -0
  61. package/dist/backend/backend/src/services/slack/room-responder.d.ts.map +1 -0
  62. package/dist/backend/backend/src/services/slack/room-responder.js +236 -0
  63. package/dist/backend/backend/src/services/slack/room-responder.js.map +1 -0
  64. package/dist/backend/backend/src/services/slack/slack-initializer.d.ts.map +1 -1
  65. package/dist/backend/backend/src/services/slack/slack-initializer.js +36 -2
  66. package/dist/backend/backend/src/services/slack/slack-initializer.js.map +1 -1
  67. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +107 -2
  68. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  69. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +403 -13
  70. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  71. package/dist/backend/backend/src/services/slack/slack-thread-context.service.d.ts +27 -0
  72. package/dist/backend/backend/src/services/slack/slack-thread-context.service.d.ts.map +1 -1
  73. package/dist/backend/backend/src/services/slack/slack-thread-context.service.js +52 -0
  74. package/dist/backend/backend/src/services/slack/slack-thread-context.service.js.map +1 -1
  75. package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.d.ts +9 -0
  76. package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.d.ts.map +1 -1
  77. package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.js +20 -0
  78. package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.js.map +1 -1
  79. package/dist/backend/backend/src/services/v3/ticket-channel-hooks.d.ts +13 -1
  80. package/dist/backend/backend/src/services/v3/ticket-channel-hooks.d.ts.map +1 -1
  81. package/dist/backend/backend/src/services/v3/ticket-channel-hooks.js +36 -3
  82. package/dist/backend/backend/src/services/v3/ticket-channel-hooks.js.map +1 -1
  83. package/dist/backend/backend/src/services/v3/workitem-dispatch.subscriber.d.ts +54 -3
  84. package/dist/backend/backend/src/services/v3/workitem-dispatch.subscriber.d.ts.map +1 -1
  85. package/dist/backend/backend/src/services/v3/workitem-dispatch.subscriber.js +117 -9
  86. package/dist/backend/backend/src/services/v3/workitem-dispatch.subscriber.js.map +1 -1
  87. package/dist/backend/backend/src/types/decision.types.d.ts +7 -0
  88. package/dist/backend/backend/src/types/decision.types.d.ts.map +1 -1
  89. package/dist/backend/build-info.json +2 -2
  90. package/dist/cli/backend/src/constants.d.ts +60 -0
  91. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  92. package/dist/cli/backend/src/constants.js +60 -0
  93. package/dist/cli/backend/src/constants.js.map +1 -1
  94. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts +117 -0
  95. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
  96. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +165 -9
  97. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
  98. package/dist/cli/backend/src/services/chat-v2/room-context-backlog.d.ts +93 -0
  99. package/dist/cli/backend/src/services/chat-v2/room-context-backlog.d.ts.map +1 -0
  100. package/dist/cli/backend/src/services/chat-v2/room-context-backlog.js +138 -0
  101. package/dist/cli/backend/src/services/chat-v2/room-context-backlog.js.map +1 -0
  102. package/dist/cli/backend/src/services/decisions/decision-card.d.ts.map +1 -1
  103. package/dist/cli/backend/src/services/decisions/decision-card.js +5 -1
  104. package/dist/cli/backend/src/services/decisions/decision-card.js.map +1 -1
  105. package/dist/cli/backend/src/services/decisions/decision.service.d.ts +26 -1
  106. package/dist/cli/backend/src/services/decisions/decision.service.d.ts.map +1 -1
  107. package/dist/cli/backend/src/services/decisions/decision.service.js +83 -5
  108. package/dist/cli/backend/src/services/decisions/decision.service.js.map +1 -1
  109. package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.d.ts +21 -0
  110. package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.d.ts.map +1 -1
  111. package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.js +48 -3
  112. package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.js.map +1 -1
  113. package/dist/cli/backend/src/services/restart/in-flight-turn-tracker.service.d.ts +9 -0
  114. package/dist/cli/backend/src/services/restart/in-flight-turn-tracker.service.d.ts.map +1 -1
  115. package/dist/cli/backend/src/services/restart/in-flight-turn-tracker.service.js +11 -0
  116. package/dist/cli/backend/src/services/restart/in-flight-turn-tracker.service.js.map +1 -1
  117. package/dist/cli/backend/src/services/slack/room-responder.d.ts +144 -0
  118. package/dist/cli/backend/src/services/slack/room-responder.d.ts.map +1 -0
  119. package/dist/cli/backend/src/services/slack/room-responder.js +236 -0
  120. package/dist/cli/backend/src/services/slack/room-responder.js.map +1 -0
  121. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +107 -2
  122. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  123. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +403 -13
  124. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  125. package/dist/cli/backend/src/services/slack/slack-thread-context.service.d.ts +27 -0
  126. package/dist/cli/backend/src/services/slack/slack-thread-context.service.d.ts.map +1 -1
  127. package/dist/cli/backend/src/services/slack/slack-thread-context.service.js +52 -0
  128. package/dist/cli/backend/src/services/slack/slack-thread-context.service.js.map +1 -1
  129. package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.d.ts +9 -0
  130. package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.d.ts.map +1 -1
  131. package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.js +20 -0
  132. package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.js.map +1 -1
  133. package/dist/cli/backend/src/services/v3/ticket-channel-hooks.d.ts +13 -1
  134. package/dist/cli/backend/src/services/v3/ticket-channel-hooks.d.ts.map +1 -1
  135. package/dist/cli/backend/src/services/v3/ticket-channel-hooks.js +36 -3
  136. package/dist/cli/backend/src/services/v3/ticket-channel-hooks.js.map +1 -1
  137. package/dist/cli/backend/src/types/decision.types.d.ts +7 -0
  138. package/dist/cli/backend/src/types/decision.types.d.ts.map +1 -1
  139. package/package.json +1 -1
@@ -27,7 +27,7 @@
27
27
  import { notePerson } from '../people/people-directory.service.js';
28
28
  import { dedicatedDecisionFor } from '../people/dedicated-agent.js';
29
29
  import { getTicketIntakeService } from '../v3/ticket-intake.service.js';
30
- import { intakeWithin, slackIntakeMessage, ticketOfOutcome, markAndLinkTicket } from '../v3/ticket-channel-hooks.js';
30
+ import { intakeWithin, slackIntakeMessage, ticketOfOutcome, markAndLinkTicket, linkLateTicket } from '../v3/ticket-channel-hooks.js';
31
31
  import { CREWLY_CONSTANTS, RUNTIME_FALLBACK_CONSTANTS } from '../../constants.js';
32
32
  import { isInterim } from './slack-typing-placeholder.service.js';
33
33
  import { isOwnerAuthored, deliveredSessions } from './slack-auto-working.service.js';
@@ -38,6 +38,8 @@ import * as path from 'path';
38
38
  import { promises as fs } from 'fs';
39
39
  import { describeSlackError } from './slack.service.js';
40
40
  import { isSilentByDefault, } from '../chat-v2/chat-v2.dispatcher.service.js';
41
+ import { ROOM_RESPONDER_CONSTANTS } from '../../constants.js';
42
+ import { buildRoomAgentDirectory, findPriorRoomAnswer, threadOwnerFromSlack, } from './room-responder.js';
41
43
  import { getCrewlyHomePath } from '../core/crewly-home.utils.js';
42
44
  import { atomicWriteJson, safeReadJson } from '../../utils/file-io.utils.js';
43
45
  import { LoggerService } from '../core/logger.service.js';
@@ -96,6 +98,27 @@ export function roomOwnerInstance(room) {
96
98
  return room.home.instanceId;
97
99
  return awake.sort()[0];
98
100
  }
101
+ /**
102
+ * The one machine that watches a shared-room message nobody on it answers:
103
+ * the room owner (#1019), else Cloud's fallback machine, else — nobody awake
104
+ * and no fallback named — the lowest instance id among the machines in the
105
+ * room that Cloud reports live (`live`, when Cloud sends it; a member it
106
+ * marks `live: false` never counts). Every machine computes the same answer
107
+ * from the same snapshot. Because the last resort may still name a machine
108
+ * that is gone, every other machine that received the message keeps a
109
+ * note-only watch (see `NOTE_ONLY_WATCH_MS`).
110
+ *
111
+ * @param room - Cloud's room presence
112
+ * @returns Instance id, or null for an empty roster
113
+ */
114
+ export function roomWatcherInstance(room) {
115
+ const owner = roomOwnerInstance(room) ?? room.fallback?.instanceId;
116
+ if (owner)
117
+ return owner;
118
+ const live = (m) => m.live;
119
+ const pool = room.members.some((m) => live(m) === true) ? room.members.filter((m) => live(m) === true) : room.members.filter((m) => live(m) !== false);
120
+ return [...new Set(pool.map((m) => m.instanceId))].sort()[0] ?? null;
121
+ }
99
122
  /**
100
123
  * Whether a mapping is an ad-hoc channel (no Crewly team behind it).
101
124
  *
@@ -1357,8 +1380,16 @@ export class SlackTeamChannelService {
1357
1380
  }
1358
1381
  return { mapping, message: persisted, mentions: [], dispatch: null };
1359
1382
  }
1383
+ // One responder per message a person wrote; everyone else listens
1384
+ // (specs/2026-10-03-one-responder-per-message.md). Agent-authored posts
1385
+ // keep the older fan-out. A step that does not finish still yields a
1386
+ // responder: without a pin the dispatcher's rules pick one.
1387
+ const oneResponder = remoteAgent
1388
+ ? null
1389
+ : await this.bounded(this.oneResponderFor(message, mapping, members, team ?? null, resolved.mentions, handoffTo, threadId, persisted.id), {}, 'responder', message);
1390
+ const routedOptions = oneResponder ? { ...dispatchOptions, oneResponder } : dispatchOptions;
1360
1391
  const planned = dispatcherForPlan?.planHuddleTargets
1361
- ? await this.bounded(dispatcherForPlan.planHuddleTargets(channel, persisted, dispatchOptions).catch(() => null), null, 'dispatch plan', message)
1392
+ ? await this.bounded(dispatcherForPlan.planHuddleTargets(channel, persisted, routedOptions).catch(() => null), null, 'dispatch plan', message)
1362
1393
  : null;
1363
1394
  // Prompt backstop: who the message (or the exchange it may continue)
1364
1395
  // was meant for, when people are in the picture. A direct request inside
@@ -1380,7 +1411,7 @@ export class SlackTeamChannelService {
1380
1411
  // the single intake. Started now, awaited just before dispatch, so the
1381
1412
  // receipt and the agent's `[TICKET:…]` marker do not hold up the eyes /
1382
1413
  // placeholders below.
1383
- const ticketPromise = this.intakeTicket(message, mapping, resolved.mentions, planned, handoffTo, remoteAgent);
1414
+ const ticketPromise = this.intakeTicket(message, mapping, resolved.mentions, planned, handoffTo, remoteAgent, persisted);
1384
1415
  await this.bounded(this.acknowledgeSeen(message, mapping, resolved.mentions, planned), undefined, 'seen reaction', message);
1385
1416
  // Agents that must reply get a placeholder straight away — "waking up…"
1386
1417
  // for an idle agent (a cold start is 1–2 minutes), "is working on it…"
@@ -1416,6 +1447,9 @@ export class SlackTeamChannelService {
1416
1447
  const typingTargets = [];
1417
1448
  if (this.deps.typing) {
1418
1449
  for (const session of owing) {
1450
+ // Rescued meanwhile: the hand-off posts its own placeholder.
1451
+ if (routeGuard?.rescued())
1452
+ break;
1419
1453
  const member = members.find((m) => m.sessionName === session);
1420
1454
  const installed = this.deps.identities?.getInstalled(session);
1421
1455
  // Own bot when installed; otherwise the master bot wearing the agent's
@@ -1425,6 +1459,7 @@ export class SlackTeamChannelService {
1425
1459
  : { displayName: member?.name ?? session, ...slackIdentityFor(member, session) };
1426
1460
  const key = { agentSession: session, slackChannelId: message.channelId, threadTs: slackThreadTs };
1427
1461
  const awake = this.deps.isAgentAwake ? this.deps.isAgentAwake(session) : true;
1462
+ routeGuard?.notePlaceholder(key);
1428
1463
  await this.bounded(this.deps.typing.begin(key, identity, awake ? 'typing' : 'waking', message.ts), null, 'placeholder', message);
1429
1464
  typingTargets.push({ session, key });
1430
1465
  }
@@ -1479,7 +1514,7 @@ export class SlackTeamChannelService {
1479
1514
  return { mapping, message: persisted, mentions: resolved.mentions, dispatch: null };
1480
1515
  }
1481
1516
  dispatch = await dispatcher.dispatchMessage(channel, markAndLinkTicket(persisted, ticket), {
1482
- ...dispatchOptions,
1517
+ ...routedOptions,
1483
1518
  ...(roster ? { channelRoster: roster } : {}),
1484
1519
  ...(peopleAddressing ? { peopleAddressing } : {}),
1485
1520
  ...(slackContext
@@ -1521,8 +1556,19 @@ export class SlackTeamChannelService {
1521
1556
  unknown: resolved.unknown.map((u) => u.token),
1522
1557
  strategy: dispatch?.strategy ?? 'none',
1523
1558
  threaded: !!threadId,
1559
+ ...(oneResponder
1560
+ ? {
1561
+ responder: oneResponder.pinned
1562
+ ? { session: oneResponder.pinned.session, name: oneResponder.pinned.name ?? null, reason: oneResponder.pinned.reason }
1563
+ : (dispatch?.huddleOutcomes ?? []).map((o) => o.sessionName),
1564
+ contextOnly: dispatch?.contextOnly ?? [],
1565
+ }
1566
+ : {}),
1524
1567
  });
1525
1568
  const recipients = deliveredSessions(dispatch);
1569
+ const heldBy = (dispatch?.huddleOutcomes ?? []).filter((o) => o.dispatched && o.queued).map((o) => o.sessionName);
1570
+ if (oneResponder)
1571
+ this.recordChosenResponders(persisted, dispatch, oneResponder);
1526
1572
  if (dispatch?.dispatched) {
1527
1573
  // Someone here holds it — including a follow-up in a thread we were
1528
1574
  // still waiting on.
@@ -1530,8 +1576,11 @@ export class SlackTeamChannelService {
1530
1576
  // The owner machine of a shared room (others deferred to it) runs the
1531
1577
  // one 90 s fallback: if nobody here answers, its lead is woken.
1532
1578
  const allOptional = (dispatch.huddleOutcomes ?? []).every((o) => o.responseMode !== 'required');
1533
- if (presence?.ownsSharedMessage && allOptional && !handoffTo && isOwnerAuthored(message, this.deps.getOwnerUserId?.())) {
1534
- this.watchUnanswered(message, mapping, persisted, dispatchOptions.threadId, recipients);
1579
+ // With one responder the other awake agents no longer hear it, so an
1580
+ // optional responder that stays silent needs the fallback in any room.
1581
+ const optionalResponder = !!oneResponder && (dispatch.huddleOutcomes ?? []).length > 0 && allOptional;
1582
+ if (((presence?.ownsSharedMessage && allOptional) || optionalResponder) && !handoffTo && isOwnerAuthored(message, this.deps.getOwnerUserId?.())) {
1583
+ this.watchUnanswered(message, mapping, persisted, dispatchOptions.threadId, recipients, heldBy);
1535
1584
  }
1536
1585
  }
1537
1586
  else if (!remoteAgent) {
@@ -1550,8 +1599,17 @@ export class SlackTeamChannelService {
1550
1599
  });
1551
1600
  // A message deferred to another machine is watched there, by its
1552
1601
  // owner, only — two machines running the fallback would answer twice.
1553
- if (!handoffTo && !presence?.deferredElsewhere && !presence?.fallbackElsewhere && isOwnerAuthored(message, this.deps.getOwnerUserId?.())) {
1554
- this.watchUnanswered(message, mapping, persisted, dispatchOptions.threadId, recipients);
1602
+ // One the decision path consumed, or whose responder runs on another
1603
+ // machine, is that path's / machine's to answer.
1604
+ // A responder on another machine is watched from the room's owner
1605
+ // machine (watchHere): never an owner message without a watcher.
1606
+ const pinned = oneResponder?.pinned;
1607
+ const watchHere = pinned?.watchHere === true;
1608
+ const noteOnly = watchHere && pinned?.watchNoteOnly === true;
1609
+ const answeredElsewhere = !!pinned && !pinned.session && !watchHere;
1610
+ const watchable = watchHere || (!answeredElsewhere && !presence?.deferredElsewhere && !presence?.fallbackElsewhere);
1611
+ if (!handoffTo && watchable && isOwnerAuthored(message, this.deps.getOwnerUserId?.())) {
1612
+ this.watchUnanswered(message, mapping, persisted, dispatchOptions.threadId, recipients, heldBy, noteOnly);
1555
1613
  }
1556
1614
  }
1557
1615
  return { mapping, message: persisted, mentions: resolved.mentions, dispatch };
@@ -1564,6 +1622,253 @@ export class SlackTeamChannelService {
1564
1622
  routeGuard?.disarm();
1565
1623
  }
1566
1624
  }
1625
+ /**
1626
+ * Who answers a person's room message, from data only this layer has
1627
+ * (specs/2026-10-03-one-responder-per-message.md §1): the decision path's
1628
+ * outcome (a), else — for an un-@'d thread reply — the Slack thread's
1629
+ * owner (c). @-mentions (b) and the top-level room rule (d) are applied by
1630
+ * the dispatcher. Never throws.
1631
+ *
1632
+ * @param message - Inbound message
1633
+ * @param mapping - Its room
1634
+ * @param members - Agents the room resolves names against (local)
1635
+ * @param team - The room's team, or null for an ad-hoc room
1636
+ * @param mentions - Local agents @'d
1637
+ * @param handoffTo - Local agent the message was handed to
1638
+ * @returns One-responder options for the dispatcher
1639
+ */
1640
+ async oneResponderFor(message, mapping, members, team, mentions, handoffTo, threadId, messageId) {
1641
+ const nameFor = (session) => members.find((m) => m.sessionName === session)?.name ??
1642
+ message.room?.members.find((m) => localAgentSession(m.agentSession) === session)?.displayName;
1643
+ const base = { nameFor };
1644
+ const isThreadReply = !!message.threadTs && message.threadTs !== message.ts;
1645
+ if (handoffTo || !isThreadReply)
1646
+ return base;
1647
+ try {
1648
+ const roomSessions = team ? teamChannelMembers(team).map((m) => m.sessionName) : [...(mapping.members ?? [])];
1649
+ const isLocal = (s) => this.deps.isLocalAgent?.(s) ?? members.some((m) => m.sessionName === s);
1650
+ // Which machine decides when the shared picture is not enough: the
1651
+ // #1019 room owner (or Cloud's fallback machine). Without presence this
1652
+ // machine is the only one that can.
1653
+ const room = message.room ?? null;
1654
+ const me = room && this.deps.resolveInstanceId ? await this.deps.resolveInstanceId().catch(() => null) : null;
1655
+ // The machine that decides and watches (owner, else fallback, else the
1656
+ // lowest instance id). A machine that cannot tell who it is watches
1657
+ // anyway: two watchers beat none (the fallback reads Slack first).
1658
+ const watcher = room ? roomWatcherInstance(room) : null;
1659
+ const ownsRoom = !room || !me || !watcher || watcher === me;
1660
+ const isWatcher = !!room && !!me && (!watcher || watcher === me);
1661
+ // Cloud named no owner and no fallback machine: the last-resort watcher
1662
+ // may be gone, so every machine that received this keeps a note-only
1663
+ // watch. So does a machine that cannot tell who it is.
1664
+ const lastResort = !!room && !roomOwnerInstance(room) && !room.fallback;
1665
+ const noteOnlyHere = !!room && !isWatcher && (!me || lastResort);
1666
+ const watchFlags = () => isWatcher ? { watchHere: true } : noteOnlyHere ? { watchHere: true, watchNoteOnly: true } : {};
1667
+ // Not ours to decide: the owner machine applies the room's rules and watches.
1668
+ const deferToRoomOwner = { ...base, pinned: { session: null, reason: 'deferred', ...watchFlags() } };
1669
+ // Ours to watch, but not to guess: nobody here answers; the 90 s watch does.
1670
+ const watchOnly = { ...base, pinned: { session: null, reason: 'deferred', ...watchFlags() } };
1671
+ // (a) The decision path: one shared run per Slack message.
1672
+ const decision = this.deps.decisionReplyFor
1673
+ ? await withinMs(this.deps.decisionReplyFor(message).catch(() => null), ROOM_RESPONDER_CONSTANTS.DECISION_WAIT_MS, null)
1674
+ : null;
1675
+ if (decision) {
1676
+ const askerHere = isLocal(decision.asker) && roomSessions.includes(decision.asker);
1677
+ const name = nameFor(decision.asker) ?? decision.asker;
1678
+ if (decision.consumed) {
1679
+ return {
1680
+ ...base,
1681
+ pinned: { session: null, answeredBy: decision.asker, name, reason: 'decision-consumed', ...(askerHere ? { alreadyHas: decision.asker } : {}) },
1682
+ };
1683
+ }
1684
+ if (askerHere)
1685
+ return { ...base, pinned: { session: decision.asker, answeredBy: decision.asker, name, reason: 'decision' } };
1686
+ // The asker is not in this room and the card is still open: the room's own rules.
1687
+ }
1688
+ if (mentions.length > 0)
1689
+ return base;
1690
+ // (c) The Slack thread's owner — the same answer on every machine.
1691
+ const read = await this.slackThreadOwner(message, members, roomSessions, isLocal);
1692
+ if (!read.readable) {
1693
+ // Machines that cannot read the thread would each guess from their
1694
+ // own log: only the room's owner machine does — and only when its log
1695
+ // can be trusted to name the last speaker. Otherwise it answers
1696
+ // nothing and keeps the 90 s watch.
1697
+ if (!ownsRoom)
1698
+ return deferToRoomOwner;
1699
+ if (!room)
1700
+ return base;
1701
+ return this.localThreadLogTrusted(mapping.chatChannelId, threadId, messageId, room, me) ? base : watchOnly;
1702
+ }
1703
+ if (!read.owner)
1704
+ return base;
1705
+ const { agent, via } = read.owner;
1706
+ // A remote owner counts only while Cloud shows it awake — an offline,
1707
+ // stale or retired machine never answers. Every machine judges by the
1708
+ // same snapshot (its own agents included). A card's asker is the
1709
+ // exception: its machine's decision path takes the reply.
1710
+ const live = !room || room.members.some((m) => localAgentSession(m.agentSession) === localAgentSession(agent.session) && m.awake);
1711
+ if (live || via === 'card') {
1712
+ if (agent.local)
1713
+ return { ...base, pinned: { session: agent.session, answeredBy: agent.session, name: agent.name, reason: 'thread-owner' } };
1714
+ // An awake asker's machine answers a card through the decision path —
1715
+ // no placeholder, no thread post — so a watch here would hand the
1716
+ // message to our lead at 90 s and answer it twice. Watch only an
1717
+ // asker that is not awake (its machine may be offline).
1718
+ const watch = !(via === 'card' && live);
1719
+ return { ...base, pinned: { session: null, answeredBy: agent.session, name: agent.name, reason: 'thread-owner', ...(watch ? watchFlags() : {}) } };
1720
+ }
1721
+ return ownsRoom ? base : deferToRoomOwner;
1722
+ }
1723
+ catch (err) {
1724
+ this.logger.warn('Could not choose the responder — using the room rules', {
1725
+ slackChannel: mapping.slackChannelName ?? message.channelId,
1726
+ error: err instanceof Error ? err.message : String(err),
1727
+ });
1728
+ }
1729
+ return base;
1730
+ }
1731
+ /**
1732
+ * Whether this machine's chat log can name a thread's last speaker when
1733
+ * Slack cannot be read. Cloud never forwards other machines' bot posts, so
1734
+ * with colleagues on other machines in the room the log is trusted only
1735
+ * when its latest agent turn in the thread is local and recent.
1736
+ *
1737
+ * @param chatChannelId - The huddle
1738
+ * @param threadId - The thread root here, when known
1739
+ * @param messageId - The message being routed (not counted)
1740
+ * @param room - Cloud's room presence
1741
+ * @param me - This machine's instance id, when known
1742
+ * @returns True when local rules may answer from the log
1743
+ */
1744
+ localThreadLogTrusted(chatChannelId, threadId, messageId, room, me) {
1745
+ const othersInRoom = room.members.some((m) => !me || m.instanceId !== me);
1746
+ if (!othersInRoom)
1747
+ return true;
1748
+ if (!threadId || !this.deps.chat.listThreadForBridge)
1749
+ return false;
1750
+ const rows = this.deps.chat.listThreadForBridge(chatChannelId, threadId).filter((r) => r.id !== messageId);
1751
+ const isLocal = (s) => this.deps.isLocalAgent?.(localAgentSession(s)) ?? false;
1752
+ // A person @'d an agent on another machine: that agent's answer (no @
1753
+ // of anyone here) never reaches this log, so it cannot name the last speaker.
1754
+ const askedElsewhere = (r) => {
1755
+ if (r.senderType !== 'user' || typeof r.metadata?.[OWNER_EVIDENCE_METADATA.REMOTE_AGENT_SESSION] === 'string')
1756
+ return false;
1757
+ const named = r.metadata?.[SLACK_TEAM_CHANNEL_CONSTANTS.AGENT_MENTIONS_METADATA_KEY];
1758
+ return Array.isArray(named) && named.some((s) => typeof s === 'string' && !isLocal(s));
1759
+ };
1760
+ for (let i = rows.length - 1; i >= 0; i--) {
1761
+ const r = rows[i];
1762
+ if (askedElsewhere(r))
1763
+ return false;
1764
+ const remote = r.senderType === 'user' && typeof r.metadata?.[OWNER_EVIDENCE_METADATA.REMOTE_AGENT_SESSION] === 'string';
1765
+ if (r.senderType !== 'agent' && !remote)
1766
+ continue;
1767
+ if (remote)
1768
+ return false;
1769
+ const now = this.deps.now ? this.deps.now().getTime() : Date.now();
1770
+ return now - r.createdAt <= ROOM_RESPONDER_CONSTANTS.LOCAL_LOG_FRESH_MS;
1771
+ }
1772
+ return false;
1773
+ }
1774
+ /**
1775
+ * Record on the owner's row who was chosen to answer it — the reply gate
1776
+ * holds only a post that repeats what one of them already said.
1777
+ *
1778
+ * @param persisted - The owner's huddle row
1779
+ * @param dispatch - What was delivered here
1780
+ * @param one - The one-responder options used
1781
+ */
1782
+ recordChosenResponders(persisted, dispatch, one) {
1783
+ const chosen = [
1784
+ ...new Set([
1785
+ ...(dispatch?.huddleOutcomes ?? []).filter((o) => o.dispatched).map((o) => o.sessionName),
1786
+ ...(one.pinned?.answeredBy ? [one.pinned.answeredBy] : []),
1787
+ ]),
1788
+ ];
1789
+ if (chosen.length === 0 || !this.deps.chat.updateMessageMetadata)
1790
+ return;
1791
+ try {
1792
+ this.deps.chat.updateMessageMetadata(persisted.id, { [ROOM_RESPONDER_CONSTANTS.CHOSEN_RESPONDERS_METADATA_KEY]: chosen });
1793
+ }
1794
+ catch {
1795
+ // The gate then holds nothing for this message: never worse than before.
1796
+ }
1797
+ }
1798
+ /**
1799
+ * The owner of the Slack thread a message replies in, read from Slack
1800
+ * (`message.threadContext`), so every machine names the same agent. Null
1801
+ * owner when no agent has spoken in it; `readable: false` when the thread
1802
+ * could not be read in time.
1803
+ *
1804
+ * @param message - Inbound thread reply
1805
+ * @param members - Local agents the room resolves names against
1806
+ * @param roomSessions - This machine's room members
1807
+ * @param isLocal - Whether a session runs here
1808
+ * @returns The thread owner, or null
1809
+ */
1810
+ async slackThreadOwner(message, members, roomSessions, isLocal) {
1811
+ let ctx = message.threadContext
1812
+ ? await withinMs(message.threadContext, ROOM_RESPONDER_CONSTANTS.THREAD_CONTEXT_WAIT_MS, null)
1813
+ : null;
1814
+ if (!ctx && this.deps.readThreadContext) {
1815
+ // One retry after a short pause; a rate limit's retry-after is waited
1816
+ // out too, up to THREAD_READ_RETRY_MAX_WAIT_MS in all.
1817
+ const backoff = ROOM_RESPONDER_CONSTANTS.THREAD_READ_RETRY_BACKOFF_MS;
1818
+ await new Promise((resolve) => {
1819
+ const t = setTimeout(resolve, backoff);
1820
+ t.unref?.();
1821
+ });
1822
+ const maxWaitMs = ROOM_RESPONDER_CONSTANTS.THREAD_READ_RETRY_MAX_WAIT_MS - backoff;
1823
+ ctx = await withinMs(this.deps.readThreadContext(message, { maxWaitMs }).catch(() => null), ROOM_RESPONDER_CONSTANTS.THREAD_CONTEXT_WAIT_MS + maxWaitMs, null);
1824
+ }
1825
+ if (!ctx)
1826
+ return { readable: false, owner: null };
1827
+ const local = members
1828
+ .filter((m) => roomSessions.includes(m.sessionName))
1829
+ .map((m) => ({ session: m.sessionName, name: m.name, botUserId: this.deps.identities?.get(m.sessionName)?.botUserId ?? null }));
1830
+ const remote = (message.room?.members ?? [])
1831
+ .filter((m) => !isLocal(localAgentSession(m.agentSession)))
1832
+ .map((m) => ({ session: m.agentSession, name: m.displayName }));
1833
+ return { readable: true, owner: threadOwnerFromSlack(ctx, buildRoomAgentDirectory({ local, remote })) };
1834
+ }
1835
+ /**
1836
+ * The reply gate (specs/2026-10-03-one-responder-per-message.md §2): an
1837
+ * agent is about to post in a room thread where a different agent already
1838
+ * answered the owner's latest message. Reads this machine's huddle thread
1839
+ * only — no Slack call.
1840
+ *
1841
+ * @param input - Conversation (huddle id), the thread named (huddle message id or Slack thread key), and the agent
1842
+ * @returns The earlier answer when the post should be held, else null
1843
+ */
1844
+ async heldReplyFor(input) {
1845
+ try {
1846
+ await this.load();
1847
+ const mapping = (this.store?.mappings ?? []).find((m) => m.chatChannelId === input.conversationId);
1848
+ const raw = input.thread?.trim();
1849
+ if (!mapping || !raw || !this.deps.chat.listThreadForBridge)
1850
+ return null;
1851
+ let rootId;
1852
+ const named = this.deps.chat.getMessageForBridge(raw);
1853
+ if (named && named.channelId === input.conversationId)
1854
+ rootId = named.threadId ?? named.id;
1855
+ const key = parseSlackThreadKey(raw);
1856
+ if (!rootId && key && key.slackChannelId === mapping.slackChannelId) {
1857
+ rootId = this.deps.chat.findSlackThreadRoot(input.conversationId, key.threadTs)?.id;
1858
+ }
1859
+ if (!rootId)
1860
+ return null;
1861
+ const thread = this.deps.chat.listThreadForBridge(input.conversationId, rootId).slice(-ROOM_RESPONDER_CONSTANTS.GATE_THREAD_SCAN);
1862
+ const local = (await this.deps.storage.getTeams()).flatMap((t) => teamChannelMembers(t));
1863
+ const nameFor = (s) => local.find((m) => m.sessionName === s)?.name ??
1864
+ this.lastRooms.get(mapping.slackChannelId)?.members.find((m) => localAgentSession(m.agentSession) === s)?.displayName;
1865
+ return findPriorRoomAnswer(thread, input.agentSession, nameFor);
1866
+ }
1867
+ catch {
1868
+ // The gate is a backstop: never block a reply because it failed.
1869
+ return null;
1870
+ }
1871
+ }
1567
1872
  /**
1568
1873
  * Hold an owner's room message that reached nobody here until an agent
1569
1874
  * takes it — on this machine or another — or the fallback runs.
@@ -1573,13 +1878,13 @@ export class SlackTeamChannelService {
1573
1878
  * @param persisted - The huddle row it was recorded as
1574
1879
  * @param threadId - Huddle thread the replies land in
1575
1880
  */
1576
- watchUnanswered(message, mapping, persisted, threadId, recipients = []) {
1881
+ watchUnanswered(message, mapping, persisted, threadId, recipients = [], heldBy = [], noteOnly = false) {
1577
1882
  const threadTs = message.threadTs || message.ts;
1578
1883
  const key = `${message.channelId}:${threadTs}`;
1579
1884
  this.settleUnanswered(key);
1580
1885
  const timer = setTimeout(() => {
1581
1886
  void this.runUnansweredFallback(key);
1582
- }, SLACK_TEAM_CHANNEL_CONSTANTS.ROOM_UNANSWERED_FALLBACK_MS);
1887
+ }, noteOnly ? ROOM_RESPONDER_CONSTANTS.NOTE_ONLY_WATCH_MS : SLACK_TEAM_CHANNEL_CONSTANTS.ROOM_UNANSWERED_FALLBACK_MS);
1583
1888
  timer.unref?.();
1584
1889
  this.unanswered.set(key, {
1585
1890
  message,
@@ -1589,6 +1894,8 @@ export class SlackTeamChannelService {
1589
1894
  threadId,
1590
1895
  messageId: persisted.id,
1591
1896
  recipients,
1897
+ ...(heldBy.length > 0 ? { heldBy } : {}),
1898
+ ...(noteOnly ? { noteOnly: true } : {}),
1592
1899
  timer,
1593
1900
  });
1594
1901
  }
@@ -1618,6 +1925,7 @@ export class SlackTeamChannelService {
1618
1925
  armRouteGuard(message, mapping, persisted, threadId) {
1619
1926
  let done = false;
1620
1927
  let rescued = false;
1928
+ const placeholders = [];
1621
1929
  const rescue = (why, err) => {
1622
1930
  if (done)
1623
1931
  return;
@@ -1633,7 +1941,17 @@ export class SlackTeamChannelService {
1633
1941
  handoff: message.handoffTo ?? null,
1634
1942
  ...(err !== undefined ? { error: err instanceof Error ? err.message : String(err) } : {}),
1635
1943
  });
1636
- void this.rescueUnroutedMessage(message, mapping, persisted, threadId).catch((rescueErr) => this.logger.error('Could not rescue an unrouted owner room message', {
1944
+ // The original routing's placeholders come down first (quietly): the
1945
+ // hand-off posts its own, and a placeholder of an agent that never
1946
+ // got the message would promise an answer nobody owes.
1947
+ const withdraw = async () => {
1948
+ for (const key of placeholders.splice(0)) {
1949
+ await this.deps.typing?.withdraw?.(key).catch(() => 0);
1950
+ }
1951
+ };
1952
+ void withdraw()
1953
+ .then(() => this.rescueUnroutedMessage(message, mapping, persisted, threadId))
1954
+ .catch((rescueErr) => this.logger.error('Could not rescue an unrouted owner room message', {
1637
1955
  ts: message.ts,
1638
1956
  error: rescueErr instanceof Error ? rescueErr.message : String(rescueErr),
1639
1957
  }));
@@ -1650,6 +1968,11 @@ export class SlackTeamChannelService {
1650
1968
  // After dispatch started, a throw is not an undelivered message:
1651
1969
  // rescuing then could deliver it twice.
1652
1970
  fail: (err) => rescue('failed', err),
1971
+ rescued: () => rescued,
1972
+ notePlaceholder: (key) => {
1973
+ if (!rescued)
1974
+ placeholders.push(key);
1975
+ },
1653
1976
  dispatchStarting: () => {
1654
1977
  if (rescued)
1655
1978
  return false;
@@ -1762,7 +2085,43 @@ export class SlackTeamChannelService {
1762
2085
  return;
1763
2086
  this.unanswered.delete(key);
1764
2087
  try {
2088
+ // The chosen responder holds it on its queue (it was busy): it will
2089
+ // answer when idle; handing it to the lead too means two answers.
2090
+ if ((pending.heldBy ?? []).length > 0) {
2091
+ this.logger.info('Unanswered room message: the responder holds it on its queue — no hand-off', {
2092
+ slackChannel: pending.slackChannelId,
2093
+ ts: pending.message.ts,
2094
+ heldBy: pending.heldBy,
2095
+ });
2096
+ return;
2097
+ }
2098
+ // An agent — here or on another machine — already replied in the
2099
+ // thread, or shows "working on it": nothing to hand over.
2100
+ const after = this.deps.slackRepliesAfter
2101
+ ? await withinMs(this.deps.slackRepliesAfter(pending.slackChannelId, pending.threadTs, pending.message.ts).catch(() => null), ROOM_RESPONDER_CONSTANTS.THREAD_CONTEXT_WAIT_MS, null)
2102
+ : null;
2103
+ const harnessNotes = [SLACK_TEAM_CHANNEL_CONSTANTS.ROOM_UNANSWERED_NOTE, SLACK_TEAM_CHANNEL_CONSTANTS.ROOM_ROUTE_STALLED_NOTE];
2104
+ if (after?.some((m) => m.isBot && !harnessNotes.includes(m.text.trim()))) {
2105
+ this.logger.info('Unanswered room message: an agent has replied or is working on it in Slack — no hand-off', {
2106
+ slackChannel: pending.slackChannelId,
2107
+ ts: pending.message.ts,
2108
+ });
2109
+ return;
2110
+ }
1765
2111
  const mapping = this.findBySlackChannelId(pending.slackChannelId);
2112
+ // Not this machine's to hand over (another may be the watcher), or the
2113
+ // thread could not be read just now: tell the owner rather than hand
2114
+ // it over blind and risk a second answer.
2115
+ const unreadable = !!this.deps.slackRepliesAfter && after === null && !!pending.timer;
2116
+ if (pending.noteOnly || unreadable) {
2117
+ this.logger.warn('Unanswered room message: telling the owner instead of handing it over', {
2118
+ slackChannel: pending.slackChannelId,
2119
+ ts: pending.message.ts,
2120
+ reason: pending.noteOnly ? 'not this machine\'s hand-off' : 'Slack thread unreadable',
2121
+ });
2122
+ await this.postUnansweredNote(pending, mapping);
2123
+ return;
2124
+ }
1766
2125
  const lead = mapping ? await this.localRoomLead(mapping) : null;
1767
2126
  if (mapping && lead && pending.recipients.includes(lead)) {
1768
2127
  // The lead already holds it (delivered as optional): handing it over
@@ -1877,7 +2236,7 @@ export class SlackTeamChannelService {
1877
2236
  * @param remoteAgent - Authoring agent, when an agent wrote it
1878
2237
  * @returns The ticket the message belongs to, or null
1879
2238
  */
1880
- async intakeTicket(message, mapping, mentions, planned, handoffTo, remoteAgent) {
2239
+ async intakeTicket(message, mapping, mentions, planned, handoffTo, remoteAgent, delivered) {
1881
2240
  try {
1882
2241
  const intake = getTicketIntakeService();
1883
2242
  if (!intake || remoteAgent)
@@ -1901,7 +2260,16 @@ export class SlackTeamChannelService {
1901
2260
  userName: message.user?.realName || message.user?.name,
1902
2261
  hasFiles: message.hasFiles,
1903
2262
  ownerUserId: this.deps.getOwnerUserId?.() ?? null,
1904
- }, 'team-channel', { ...(targetAgent ? { targetAgent } : {}), ...(postAs ? { receiptPostAs: postAs } : {}) }));
2263
+ }, 'team-channel', { ...(targetAgent ? { targetAgent } : {}), ...(postAs ? { receiptPostAs: postAs } : {}) }), undefined,
2264
+ // Filed after the message went out without its marker: link the
2265
+ // ticket to the copy that was dispatched (crewly#1015 follow-up L2).
2266
+ delivered
2267
+ ? (late) => {
2268
+ const linked = linkLateTicket(late, delivered);
2269
+ if (linked)
2270
+ this.logger.info('Late ticket linked to the delivered message', { ticketId: linked.id, messageId: delivered.id });
2271
+ }
2272
+ : undefined);
1905
2273
  return ticketOfOutcome(outcome);
1906
2274
  }
1907
2275
  catch (err) {
@@ -3169,6 +3537,28 @@ export class SlackTeamChannelService {
3169
3537
  }
3170
3538
  }
3171
3539
  /** Type guard for persisted mapping rows. */
3540
+ /**
3541
+ * A promise's value, or `fallback` when it takes longer than `ms`.
3542
+ *
3543
+ * @param promise - What to wait for
3544
+ * @param ms - Longest wait
3545
+ * @param fallback - Value on timeout
3546
+ * @returns The value, or the fallback
3547
+ */
3548
+ async function withinMs(promise, ms, fallback) {
3549
+ let timer;
3550
+ const timeout = new Promise((resolve) => {
3551
+ timer = setTimeout(() => resolve(fallback), ms);
3552
+ timer.unref?.();
3553
+ });
3554
+ try {
3555
+ return await Promise.race([promise, timeout]);
3556
+ }
3557
+ finally {
3558
+ if (timer)
3559
+ clearTimeout(timer);
3560
+ }
3561
+ }
3172
3562
  function isMapping(value) {
3173
3563
  const v = value;
3174
3564
  return (!!v &&