crewly 1.20.202 → 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.
- package/config/skills/agent/core/reply/SKILL.md +1 -0
- package/config/skills/agent/core/reply/execute.sh +7 -1
- package/config/skills/agent/core/reply-channel/SKILL.md +4 -0
- package/config/skills/agent/core/reply-channel/execute.sh +12 -1
- package/dist/backend/backend/src/constants.d.ts +158 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +158 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js +1 -0
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/chat/chat.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/chat/chat.controller.js +11 -1
- package/dist/backend/backend/src/controllers/chat/chat.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js +50 -0
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/monitoring/terminal.controller.d.ts +6 -0
- package/dist/backend/backend/src/controllers/monitoring/terminal.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/monitoring/terminal.controller.js +30 -2
- package/dist/backend/backend/src/controllers/monitoring/terminal.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/session/session.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/session/session.controller.js +7 -0
- package/dist/backend/backend/src/controllers/session/session.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.js +4 -1
- package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts +28 -0
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +141 -35
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/agent-registration.service.js +7 -0
- package/dist/backend/backend/src/services/agent/agent-registration.service.js.map +1 -1
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +11 -0
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -1
- package/dist/backend/backend/src/services/agent/fresh-task-conversation.service.d.ts +22 -0
- package/dist/backend/backend/src/services/agent/fresh-task-conversation.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/fresh-task-conversation.service.js +39 -3
- package/dist/backend/backend/src/services/agent/fresh-task-conversation.service.js.map +1 -1
- package/dist/backend/backend/src/services/agent/in-process-turn-failure.service.d.ts +140 -0
- package/dist/backend/backend/src/services/agent/in-process-turn-failure.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/agent/in-process-turn-failure.service.js +267 -0
- package/dist/backend/backend/src/services/agent/in-process-turn-failure.service.js.map +1 -0
- package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts +117 -0
- package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +165 -9
- package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/room-context-backlog.d.ts +93 -0
- package/dist/backend/backend/src/services/chat-v2/room-context-backlog.d.ts.map +1 -0
- package/dist/backend/backend/src/services/chat-v2/room-context-backlog.js +138 -0
- package/dist/backend/backend/src/services/chat-v2/room-context-backlog.js.map +1 -0
- package/dist/backend/backend/src/services/decisions/decision-card.d.ts.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision-card.js +5 -1
- package/dist/backend/backend/src/services/decisions/decision-card.js.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.service.d.ts +43 -1
- package/dist/backend/backend/src/services/decisions/decision.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.service.js +110 -7
- package/dist/backend/backend/src/services/decisions/decision.service.js.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.wiring.d.ts +2 -0
- package/dist/backend/backend/src/services/decisions/decision.wiring.d.ts.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.wiring.js +1 -0
- package/dist/backend/backend/src/services/decisions/decision.wiring.js.map +1 -1
- package/dist/backend/backend/src/services/messaging/drain-queued-delivery.d.ts +59 -0
- package/dist/backend/backend/src/services/messaging/drain-queued-delivery.d.ts.map +1 -0
- package/dist/backend/backend/src/services/messaging/drain-queued-delivery.js +70 -0
- package/dist/backend/backend/src/services/messaging/drain-queued-delivery.js.map +1 -0
- package/dist/backend/backend/src/services/messaging/owner-message-watchdog.service.d.ts +46 -1
- package/dist/backend/backend/src/services/messaging/owner-message-watchdog.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/messaging/owner-message-watchdog.service.js +109 -0
- package/dist/backend/backend/src/services/messaging/owner-message-watchdog.service.js.map +1 -1
- package/dist/backend/backend/src/services/messaging/owner-message-watchdog.wiring.d.ts +2 -0
- package/dist/backend/backend/src/services/messaging/owner-message-watchdog.wiring.d.ts.map +1 -1
- package/dist/backend/backend/src/services/messaging/owner-message-watchdog.wiring.js +6 -0
- package/dist/backend/backend/src/services/messaging/owner-message-watchdog.wiring.js.map +1 -1
- package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.d.ts +51 -1
- package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.js +73 -3
- package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.js.map +1 -1
- package/dist/backend/backend/src/services/monitoring/liveness-monitor.service.d.ts +168 -0
- package/dist/backend/backend/src/services/monitoring/liveness-monitor.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/monitoring/liveness-monitor.service.js +280 -0
- package/dist/backend/backend/src/services/monitoring/liveness-monitor.service.js.map +1 -0
- package/dist/backend/backend/src/services/open-items/open-items.service.d.ts +21 -0
- package/dist/backend/backend/src/services/open-items/open-items.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/open-items/open-items.service.js +72 -3
- package/dist/backend/backend/src/services/open-items/open-items.service.js.map +1 -1
- package/dist/backend/backend/src/services/orc/orc-chat-owner-mirror.d.ts +153 -0
- package/dist/backend/backend/src/services/orc/orc-chat-owner-mirror.d.ts.map +1 -0
- package/dist/backend/backend/src/services/orc/orc-chat-owner-mirror.js +276 -0
- package/dist/backend/backend/src/services/orc/orc-chat-owner-mirror.js.map +1 -0
- package/dist/backend/backend/src/services/orc/reply-destination.wiring.d.ts +16 -0
- package/dist/backend/backend/src/services/orc/reply-destination.wiring.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orc/reply-destination.wiring.js +22 -2
- package/dist/backend/backend/src/services/orc/reply-destination.wiring.js.map +1 -1
- package/dist/backend/backend/src/services/restart/in-flight-turn-tracker.service.d.ts +9 -0
- package/dist/backend/backend/src/services/restart/in-flight-turn-tracker.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/restart/in-flight-turn-tracker.service.js +11 -0
- package/dist/backend/backend/src/services/restart/in-flight-turn-tracker.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/room-responder.d.ts +144 -0
- package/dist/backend/backend/src/services/slack/room-responder.d.ts.map +1 -0
- package/dist/backend/backend/src/services/slack/room-responder.js +236 -0
- package/dist/backend/backend/src/services/slack/room-responder.js.map +1 -0
- package/dist/backend/backend/src/services/slack/slack-agent-dm.service.d.ts +11 -2
- package/dist/backend/backend/src/services/slack/slack-agent-dm.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-agent-dm.service.js +22 -1
- package/dist/backend/backend/src/services/slack/slack-agent-dm.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-initializer.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-initializer.js +64 -2
- package/dist/backend/backend/src/services/slack/slack-initializer.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-relogin-dm.service.d.ts +8 -2
- package/dist/backend/backend/src/services/slack/slack-relogin-dm.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-relogin-dm.service.js +7 -4
- package/dist/backend/backend/src/services/slack/slack-relogin-dm.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +148 -2
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +784 -231
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-thread-context.service.d.ts +27 -0
- package/dist/backend/backend/src/services/slack/slack-thread-context.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-thread-context.service.js +52 -0
- package/dist/backend/backend/src/services/slack/slack-thread-context.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.d.ts +30 -5
- package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.js +41 -8
- package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.js.map +1 -1
- package/dist/backend/backend/src/services/task-pool/task-pool.service.d.ts +1 -0
- package/dist/backend/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/task-pool/task-pool.service.js +8 -3
- package/dist/backend/backend/src/services/task-pool/task-pool.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/ticket-channel-hooks.d.ts +13 -1
- package/dist/backend/backend/src/services/v3/ticket-channel-hooks.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/ticket-channel-hooks.js +36 -3
- package/dist/backend/backend/src/services/v3/ticket-channel-hooks.js.map +1 -1
- package/dist/backend/backend/src/services/v3/workitem-dispatch.subscriber.d.ts +54 -3
- package/dist/backend/backend/src/services/v3/workitem-dispatch.subscriber.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/workitem-dispatch.subscriber.js +117 -9
- package/dist/backend/backend/src/services/v3/workitem-dispatch.subscriber.js.map +1 -1
- package/dist/backend/backend/src/types/decision.types.d.ts +7 -0
- package/dist/backend/backend/src/types/decision.types.d.ts.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +158 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +158 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/agent-registration.service.js +7 -0
- package/dist/cli/backend/src/services/agent/agent-registration.service.js.map +1 -1
- package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +11 -0
- package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -1
- package/dist/cli/backend/src/services/agent/fresh-task-conversation.service.d.ts +22 -0
- package/dist/cli/backend/src/services/agent/fresh-task-conversation.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/fresh-task-conversation.service.js +39 -3
- package/dist/cli/backend/src/services/agent/fresh-task-conversation.service.js.map +1 -1
- package/dist/cli/backend/src/services/agent/in-process-turn-failure.service.d.ts +140 -0
- package/dist/cli/backend/src/services/agent/in-process-turn-failure.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/agent/in-process-turn-failure.service.js +267 -0
- package/dist/cli/backend/src/services/agent/in-process-turn-failure.service.js.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts +117 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +165 -9
- package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/room-context-backlog.d.ts +93 -0
- package/dist/cli/backend/src/services/chat-v2/room-context-backlog.d.ts.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/room-context-backlog.js +138 -0
- package/dist/cli/backend/src/services/chat-v2/room-context-backlog.js.map +1 -0
- package/dist/cli/backend/src/services/decisions/decision-card.d.ts.map +1 -1
- package/dist/cli/backend/src/services/decisions/decision-card.js +5 -1
- package/dist/cli/backend/src/services/decisions/decision-card.js.map +1 -1
- package/dist/cli/backend/src/services/decisions/decision.service.d.ts +43 -1
- package/dist/cli/backend/src/services/decisions/decision.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/decisions/decision.service.js +110 -7
- package/dist/cli/backend/src/services/decisions/decision.service.js.map +1 -1
- package/dist/cli/backend/src/services/messaging/owner-message-watchdog.service.d.ts +46 -1
- package/dist/cli/backend/src/services/messaging/owner-message-watchdog.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/messaging/owner-message-watchdog.service.js +109 -0
- package/dist/cli/backend/src/services/messaging/owner-message-watchdog.service.js.map +1 -1
- package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.d.ts +51 -1
- package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.js +73 -3
- package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.js.map +1 -1
- package/dist/cli/backend/src/services/open-items/open-items.service.d.ts +21 -0
- package/dist/cli/backend/src/services/open-items/open-items.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/open-items/open-items.service.js +72 -3
- package/dist/cli/backend/src/services/open-items/open-items.service.js.map +1 -1
- package/dist/cli/backend/src/services/restart/in-flight-turn-tracker.service.d.ts +9 -0
- package/dist/cli/backend/src/services/restart/in-flight-turn-tracker.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/restart/in-flight-turn-tracker.service.js +11 -0
- package/dist/cli/backend/src/services/restart/in-flight-turn-tracker.service.js.map +1 -1
- package/dist/cli/backend/src/services/runtime-fallback/reset-time.d.ts +66 -0
- package/dist/cli/backend/src/services/runtime-fallback/reset-time.d.ts.map +1 -0
- package/dist/cli/backend/src/services/runtime-fallback/reset-time.js +211 -0
- package/dist/cli/backend/src/services/runtime-fallback/reset-time.js.map +1 -0
- package/dist/cli/backend/src/services/runtime-fallback/usage-limit-rules.d.ts +113 -0
- package/dist/cli/backend/src/services/runtime-fallback/usage-limit-rules.d.ts.map +1 -0
- package/dist/cli/backend/src/services/runtime-fallback/usage-limit-rules.js +257 -0
- package/dist/cli/backend/src/services/runtime-fallback/usage-limit-rules.js.map +1 -0
- package/dist/cli/backend/src/services/slack/room-responder.d.ts +144 -0
- package/dist/cli/backend/src/services/slack/room-responder.d.ts.map +1 -0
- package/dist/cli/backend/src/services/slack/room-responder.js +236 -0
- package/dist/cli/backend/src/services/slack/room-responder.js.map +1 -0
- package/dist/cli/backend/src/services/slack/slack-agent-dm.service.d.ts +11 -2
- package/dist/cli/backend/src/services/slack/slack-agent-dm.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-agent-dm.service.js +22 -1
- package/dist/cli/backend/src/services/slack/slack-agent-dm.service.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +148 -2
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +784 -231
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-thread-context.service.d.ts +27 -0
- package/dist/cli/backend/src/services/slack/slack-thread-context.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-thread-context.service.js +52 -0
- package/dist/cli/backend/src/services/slack/slack-thread-context.service.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.d.ts +30 -5
- package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.js +41 -8
- package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.js.map +1 -1
- package/dist/cli/backend/src/services/task-pool/task-pool.service.d.ts +1 -0
- package/dist/cli/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/task-pool/task-pool.service.js +8 -3
- package/dist/cli/backend/src/services/task-pool/task-pool.service.js.map +1 -1
- package/dist/cli/backend/src/services/v3/ticket-channel-hooks.d.ts +13 -1
- package/dist/cli/backend/src/services/v3/ticket-channel-hooks.d.ts.map +1 -1
- package/dist/cli/backend/src/services/v3/ticket-channel-hooks.js +36 -3
- package/dist/cli/backend/src/services/v3/ticket-channel-hooks.js.map +1 -1
- package/dist/cli/backend/src/types/decision.types.d.ts +7 -0
- package/dist/cli/backend/src/types/decision.types.d.ts.map +1 -1
- 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
|
*
|
|
@@ -1282,255 +1305,569 @@ export class SlackTeamChannelService {
|
|
|
1282
1305
|
if (oldest !== undefined)
|
|
1283
1306
|
this.seenInbound.delete(oldest);
|
|
1284
1307
|
}
|
|
1285
|
-
//
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1308
|
+
// Route guard (crewly#1015 §7): from here on an owner's message is
|
|
1309
|
+
// recorded but not yet delivered. "旧模板是什么" (10-02, #C0C46TTBNNP)
|
|
1310
|
+
// was recorded and then nothing — no "routed", no "reached nobody", no
|
|
1311
|
+
// watchdog entry: one of the awaits below never settled, and every
|
|
1312
|
+
// safeguard sits after them. Each await before dispatch is bounded
|
|
1313
|
+
// (ROUTE_STEP_TIMEOUT_MS); a routing that throws, or has not reached
|
|
1314
|
+
// dispatch within ROUTE_STALL_MS, is rescued by the unanswered-message
|
|
1315
|
+
// fallback. Dispatch itself is never timed (sequential cold starts can
|
|
1316
|
+
// take minutes), and a routing that reaches dispatch after a rescue ran
|
|
1317
|
+
// stops there — no double delivery.
|
|
1318
|
+
const routeGuard = !remoteAgent && isOwnerAuthored(message, this.deps.getOwnerUserId?.())
|
|
1319
|
+
? this.armRouteGuard(message, mapping, persisted, threadId ?? persisted.id)
|
|
1320
|
+
: null;
|
|
1321
|
+
try {
|
|
1322
|
+
// Ad-hoc channels grow their huddle as new agents get @'d there.
|
|
1323
|
+
if (isAdhocMapping(mapping) && resolved.mentions.length > 0) {
|
|
1324
|
+
const next = [...new Set([...(mapping.members ?? []), ...resolved.mentions])];
|
|
1325
|
+
if (next.length !== (mapping.members ?? []).length) {
|
|
1326
|
+
mapping.members = next;
|
|
1327
|
+
this.deps.chat.setHuddleMembers(mapping.chatChannelId, next);
|
|
1328
|
+
await this.save();
|
|
1329
|
+
this.deps.onRoomsChanged?.();
|
|
1330
|
+
}
|
|
1293
1331
|
}
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
return { mapping, message: persisted, mentions: [], dispatch: null };
|
|
1312
|
-
}
|
|
1313
|
-
if (addressedElsewhereOnly) {
|
|
1314
|
-
// Kept for context (the next question in the thread may be ours), but
|
|
1315
|
-
// no eyes, no placeholder, and nobody here is told.
|
|
1316
|
-
this.logger.info('Slack team message addressed to an agent on another machine — recorded, not dispatched', {
|
|
1317
|
-
teamId: mapping.teamId,
|
|
1318
|
-
slackChannel: `#${mapping.slackChannelName}`,
|
|
1319
|
-
mentionedElsewhere,
|
|
1320
|
-
});
|
|
1321
|
-
return { mapping, message: persisted, mentions: [], dispatch: null };
|
|
1322
|
-
}
|
|
1323
|
-
if (addressedPeopleOnly) {
|
|
1324
|
-
// Context for the agents' next turn in this thread, nothing more. No
|
|
1325
|
-
// suggestion hint either: the names were people, not typos.
|
|
1326
|
-
if (inherited) {
|
|
1327
|
-
this.logger.info('Slack team message continues a person-to-person exchange — recorded, not dispatched', {
|
|
1328
|
-
teamId: mapping.teamId,
|
|
1329
|
-
slackChannel: `#${mapping.slackChannelName}`,
|
|
1330
|
-
addressedTo: inherited.people,
|
|
1331
|
-
reason: inherited.reason,
|
|
1332
|
-
threaded: !!threadId,
|
|
1333
|
-
});
|
|
1332
|
+
// Who will receive this, and who owes a reply — decided by the same rules
|
|
1333
|
+
// delivery uses, but *before* delivery, which can take a minute or two
|
|
1334
|
+
// when an agent has to be cold-started. The owner should not look at an
|
|
1335
|
+
// unacknowledged message for that long.
|
|
1336
|
+
const dispatcherForPlan = this.deps.getDispatcher();
|
|
1337
|
+
const presence = await this.bounded(this.roomStateFor(message, mapping, team ?? null), null, 'room presence', message);
|
|
1338
|
+
const dispatchOptions = {
|
|
1339
|
+
threadId: threadId ?? persisted.id,
|
|
1340
|
+
replyVia: 'reply-channel',
|
|
1341
|
+
// A local agent's own message (fanned out to the colleagues it @'d)
|
|
1342
|
+
// must not come back to its author.
|
|
1343
|
+
...(remoteAgent ? { excludeSessions: [remoteAgent] } : {}),
|
|
1344
|
+
...(presence ? { room: presence.state, ...(presence.line ? { roomPresence: presence.line } : {}) } : {}),
|
|
1345
|
+
};
|
|
1346
|
+
if (declinedOnly) {
|
|
1347
|
+
// Every agent it addressed is dedicated to someone else and declined.
|
|
1348
|
+
return { mapping, message: persisted, mentions: [], dispatch: null };
|
|
1334
1349
|
}
|
|
1335
|
-
|
|
1336
|
-
|
|
1350
|
+
if (addressedElsewhereOnly) {
|
|
1351
|
+
// Kept for context (the next question in the thread may be ours), but
|
|
1352
|
+
// no eyes, no placeholder, and nobody here is told.
|
|
1353
|
+
this.logger.info('Slack team message addressed to an agent on another machine — recorded, not dispatched', {
|
|
1337
1354
|
teamId: mapping.teamId,
|
|
1338
1355
|
slackChannel: `#${mapping.slackChannelName}`,
|
|
1339
|
-
|
|
1340
|
-
mentionedNames: peopleMentions.names,
|
|
1341
|
-
threaded: !!threadId,
|
|
1356
|
+
mentionedElsewhere,
|
|
1342
1357
|
});
|
|
1358
|
+
return { mapping, message: persisted, mentions: [], dispatch: null };
|
|
1343
1359
|
}
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1360
|
+
if (addressedPeopleOnly) {
|
|
1361
|
+
// Context for the agents' next turn in this thread, nothing more. No
|
|
1362
|
+
// suggestion hint either: the names were people, not typos.
|
|
1363
|
+
if (inherited) {
|
|
1364
|
+
this.logger.info('Slack team message continues a person-to-person exchange — recorded, not dispatched', {
|
|
1365
|
+
teamId: mapping.teamId,
|
|
1366
|
+
slackChannel: `#${mapping.slackChannelName}`,
|
|
1367
|
+
addressedTo: inherited.people,
|
|
1368
|
+
reason: inherited.reason,
|
|
1369
|
+
threaded: !!threadId,
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
else {
|
|
1373
|
+
this.logger.info('Slack team message addressed to people, not agents — recorded, not dispatched', {
|
|
1374
|
+
teamId: mapping.teamId,
|
|
1375
|
+
slackChannel: `#${mapping.slackChannelName}`,
|
|
1376
|
+
mentionedUsers: peopleMentions.userIds,
|
|
1377
|
+
mentionedNames: peopleMentions.names,
|
|
1378
|
+
threaded: !!threadId,
|
|
1379
|
+
});
|
|
1359
1380
|
}
|
|
1381
|
+
return { mapping, message: persisted, mentions: [], dispatch: null };
|
|
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;
|
|
1391
|
+
const planned = dispatcherForPlan?.planHuddleTargets
|
|
1392
|
+
? await this.bounded(dispatcherForPlan.planHuddleTargets(channel, persisted, routedOptions).catch(() => null), null, 'dispatch plan', message)
|
|
1360
1393
|
: null;
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
//
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
//
|
|
1384
|
-
//
|
|
1385
|
-
//
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
//
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1394
|
+
// Prompt backstop: who the message (or the exchange it may continue)
|
|
1395
|
+
// was meant for, when people are in the picture. A direct request inside
|
|
1396
|
+
// the exchange gets a neutral note, and none at all in an assistant or
|
|
1397
|
+
// support room, where requests are what the team is there for.
|
|
1398
|
+
const peopleAddressing = namedPeople.length > 0
|
|
1399
|
+
? { kind: 'named-in-message', people: await this.personLabels(namedPeople, message.channelId) }
|
|
1400
|
+
: exchange.recent.length > 0 && !(exchange.request && isAssistantRoom(team ?? null, mapping.slackChannelName))
|
|
1401
|
+
? {
|
|
1402
|
+
kind: exchange.request ? 'recent-exchange-request' : 'recent-exchange',
|
|
1403
|
+
people: await this.personLabels(exchange.recent, message.channelId),
|
|
1404
|
+
}
|
|
1405
|
+
: null;
|
|
1406
|
+
// A recipient told to stay silent by default gets no placeholder and no
|
|
1407
|
+
// auto "working on it": both promise a reply the prompt does not ask for.
|
|
1408
|
+
const addressedHere = new Set(persisted.mentions ?? []);
|
|
1409
|
+
const silentByDefault = (session) => isSilentByDefault(peopleAddressing, planned?.get(session) ?? 'optional', addressedHere.has(session));
|
|
1410
|
+
// Ticket loop (specs/ticket-loop.md §2): the owner's message goes through
|
|
1411
|
+
// the single intake. Started now, awaited just before dispatch, so the
|
|
1412
|
+
// receipt and the agent's `[TICKET:…]` marker do not hold up the eyes /
|
|
1413
|
+
// placeholders below.
|
|
1414
|
+
const ticketPromise = this.intakeTicket(message, mapping, resolved.mentions, planned, handoffTo, remoteAgent, persisted);
|
|
1415
|
+
await this.bounded(this.acknowledgeSeen(message, mapping, resolved.mentions, planned), undefined, 'seen reaction', message);
|
|
1416
|
+
// Agents that must reply get a placeholder straight away — "waking up…"
|
|
1417
|
+
// for an idle agent (a cold start is 1–2 minutes), "is working on it…"
|
|
1418
|
+
// once it holds the message. Agents that were only told, and may or may
|
|
1419
|
+
// not decide to answer, get none: a placeholder is a promise of a reply,
|
|
1420
|
+
// and they announce their own with `reply-channel --working` if they
|
|
1421
|
+
// take it on. With a plan this covers the case the old heuristic missed —
|
|
1422
|
+
// a bare follow-up in a thread, which the last speaker must answer.
|
|
1423
|
+
let owing;
|
|
1424
|
+
if (planned) {
|
|
1425
|
+
// An agent that is asleep and still gets the message is being woken
|
|
1426
|
+
// for it — the room's leader when nobody was awake — so it owns the
|
|
1427
|
+
// message and the owner should see that at once, not after the one or
|
|
1428
|
+
// two minutes a cold start takes (#pro-crewly-marketing, 2026-09-23).
|
|
1429
|
+
// Awake agents that were only told announce themselves with --working.
|
|
1430
|
+
// The orchestrator is left out: its bot is usually not in the room.
|
|
1431
|
+
const isAwake = this.deps.isAgentAwake;
|
|
1432
|
+
owing = [...planned]
|
|
1433
|
+
.filter(([session, mode]) => mode === 'required' ||
|
|
1434
|
+
(isAwake !== undefined && !isAwake(session) && session !== CREWLY_CONSTANTS.SESSIONS.ORCHESTRATOR_NAME))
|
|
1435
|
+
.map(([session]) => session);
|
|
1399
1436
|
}
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
// name/icon — the person should see *something* during a cold start.
|
|
1409
|
-
const identity = installed
|
|
1410
|
-
? { botToken: installed.botToken, displayName: member?.name ?? session }
|
|
1411
|
-
: { displayName: member?.name ?? session, ...slackIdentityFor(member, session) };
|
|
1412
|
-
const key = { agentSession: session, slackChannelId: message.channelId, threadTs: slackThreadTs };
|
|
1413
|
-
const awake = this.deps.isAgentAwake ? this.deps.isAgentAwake(session) : true;
|
|
1414
|
-
await this.deps.typing.begin(key, identity, awake ? 'typing' : 'waking', message.ts);
|
|
1415
|
-
typingTargets.push({ session, key });
|
|
1437
|
+
else {
|
|
1438
|
+
owing = resolved.mentions;
|
|
1439
|
+
if (owing.length === 0 && !message.threadTs && team) {
|
|
1440
|
+
// Same rule as the dispatcher's huddleLeaderFor: the team lead, else the first member.
|
|
1441
|
+
const leader = teamChannelLeader(team, members);
|
|
1442
|
+
if (leader)
|
|
1443
|
+
owing = [leader.sessionName];
|
|
1444
|
+
}
|
|
1416
1445
|
}
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
const autoWatch = this.deps.autoWorking && this.deps.typing && autoWatchCandidates.length > 0 && isOwnerAuthored(message, this.deps.getOwnerUserId?.())
|
|
1425
|
-
? this.deps.autoWorking.watch({
|
|
1426
|
-
slackChannelId: message.channelId,
|
|
1427
|
-
threadTs: slackThreadTs,
|
|
1428
|
-
sourceTs: message.ts,
|
|
1429
|
-
candidates: autoWatchCandidates,
|
|
1430
|
-
identityFor: (session) => {
|
|
1446
|
+
owing = owing.filter((session) => !silentByDefault(session));
|
|
1447
|
+
const typingTargets = [];
|
|
1448
|
+
if (this.deps.typing) {
|
|
1449
|
+
for (const session of owing) {
|
|
1450
|
+
// Rescued meanwhile: the hand-off posts its own placeholder.
|
|
1451
|
+
if (routeGuard?.rescued())
|
|
1452
|
+
break;
|
|
1431
1453
|
const member = members.find((m) => m.sessionName === session);
|
|
1432
1454
|
const installed = this.deps.identities?.getInstalled(session);
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
//
|
|
1446
|
-
//
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1455
|
+
// Own bot when installed; otherwise the master bot wearing the agent's
|
|
1456
|
+
// name/icon — the person should see *something* during a cold start.
|
|
1457
|
+
const identity = installed
|
|
1458
|
+
? { botToken: installed.botToken, displayName: member?.name ?? session }
|
|
1459
|
+
: { displayName: member?.name ?? session, ...slackIdentityFor(member, session) };
|
|
1460
|
+
const key = { agentSession: session, slackChannelId: message.channelId, threadTs: slackThreadTs };
|
|
1461
|
+
const awake = this.deps.isAgentAwake ? this.deps.isAgentAwake(session) : true;
|
|
1462
|
+
routeGuard?.notePlaceholder(key);
|
|
1463
|
+
await this.bounded(this.deps.typing.begin(key, identity, awake ? 'typing' : 'waking', message.ts), null, 'placeholder', message);
|
|
1464
|
+
typingTargets.push({ session, key });
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
// Recipients who were only told get no placeholder above; the first of
|
|
1468
|
+
// them that starts working on an owner's message gets one posted for it
|
|
1469
|
+
// by the harness (2026-09-30: Owen, 3.5 min of nothing in #pro-ce).
|
|
1470
|
+
const autoWatchCandidates = [
|
|
1471
|
+
...new Set([...(planned ? [...planned.keys()] : []), ...members.map((m) => m.sessionName)]),
|
|
1472
|
+
].filter((session) => !silentByDefault(session));
|
|
1473
|
+
const autoWatch = this.deps.autoWorking && this.deps.typing && autoWatchCandidates.length > 0 && isOwnerAuthored(message, this.deps.getOwnerUserId?.())
|
|
1474
|
+
? this.deps.autoWorking.watch({
|
|
1475
|
+
slackChannelId: message.channelId,
|
|
1476
|
+
threadTs: slackThreadTs,
|
|
1477
|
+
sourceTs: message.ts,
|
|
1478
|
+
candidates: autoWatchCandidates,
|
|
1479
|
+
identityFor: (session) => {
|
|
1480
|
+
const member = members.find((m) => m.sessionName === session);
|
|
1481
|
+
const installed = this.deps.identities?.getInstalled(session);
|
|
1482
|
+
const displayName = member?.name ?? session;
|
|
1483
|
+
return installed
|
|
1484
|
+
? { botToken: installed.botToken, displayName }
|
|
1485
|
+
: { displayName, ...slackIdentityFor(member, session) };
|
|
1486
|
+
},
|
|
1487
|
+
})
|
|
1488
|
+
: null;
|
|
1489
|
+
const dispatcher = this.deps.getDispatcher();
|
|
1490
|
+
let dispatch = null;
|
|
1491
|
+
if (dispatcher) {
|
|
1492
|
+
let roster = await this.bounded(getSlackDirectoryService()?.rosterLine(message.channelId).catch(() => '') ?? Promise.resolve(''), '', 'room roster', message);
|
|
1493
|
+
// The directory needs the master bot to list a channel's members; in a
|
|
1494
|
+
// private ad-hoc channel it is not a member, so fall back to the local
|
|
1495
|
+
// huddle roster (with roles) — enough to answer "who leads this?".
|
|
1496
|
+
if (!roster && isAdhocMapping(mapping)) {
|
|
1497
|
+
roster = members
|
|
1498
|
+
.filter((m) => (mapping.members ?? []).includes(m.sessionName))
|
|
1499
|
+
.map((m) => `${m.name} (${teams.find((t) => (t.members ?? []).some((x) => x.id === m.id))?.name ?? '?'}, ${String(m.role)}, this machine)`)
|
|
1500
|
+
.join(' · ');
|
|
1501
|
+
}
|
|
1502
|
+
const ticket = await this.bounded(ticketPromise, null, 'ticket intake', message);
|
|
1503
|
+
// The thread as Slack has it — posts by agents on other machines
|
|
1504
|
+
// included — rendered per recipient so its own lines are marked.
|
|
1505
|
+
const slackContext = await this.bounded(Promise.resolve(message.threadContext), undefined, 'thread context', message);
|
|
1506
|
+
// A rescue already ran (routing stalled before this point): it has
|
|
1507
|
+
// handed the message over, so this late routing must not deliver it
|
|
1508
|
+
// again, nor arm another unanswered watch.
|
|
1509
|
+
if (routeGuard && !routeGuard.dispatchStarting()) {
|
|
1510
|
+
this.logger.warn('Owner room message reached dispatch after it was rescued — not dispatching it again', {
|
|
1511
|
+
slackChannel: `#${mapping.slackChannelName}`,
|
|
1512
|
+
ts: message.ts,
|
|
1513
|
+
});
|
|
1514
|
+
return { mapping, message: persisted, mentions: resolved.mentions, dispatch: null };
|
|
1515
|
+
}
|
|
1516
|
+
dispatch = await dispatcher.dispatchMessage(channel, markAndLinkTicket(persisted, ticket), {
|
|
1517
|
+
...routedOptions,
|
|
1518
|
+
...(roster ? { channelRoster: roster } : {}),
|
|
1519
|
+
...(peopleAddressing ? { peopleAddressing } : {}),
|
|
1520
|
+
...(slackContext
|
|
1521
|
+
? {
|
|
1522
|
+
slackContextFor: (session) => renderSlackThreadContext(slackContext, {
|
|
1523
|
+
botUserId: this.deps.identities?.get(session)?.botUserId,
|
|
1524
|
+
name: members.find((m) => m.sessionName === session)?.name,
|
|
1525
|
+
}),
|
|
1526
|
+
}
|
|
1527
|
+
: {}),
|
|
1528
|
+
});
|
|
1452
1529
|
}
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1530
|
+
else {
|
|
1531
|
+
this.logger.warn('No chat dispatcher wired — message persisted but not delivered', {
|
|
1532
|
+
huddle: mapping.chatChannelId,
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
if (resolved.unknown.length > 0) {
|
|
1536
|
+
await this.postUnknownMentionHint(message, resolved.unknown, candidates);
|
|
1537
|
+
}
|
|
1538
|
+
autoWatch?.delivered(deliveredSessions(dispatch));
|
|
1539
|
+
// Dispatch is done: each placeholder now reflects whether its agent
|
|
1540
|
+
// actually holds the message.
|
|
1541
|
+
if (this.deps.typing && typingTargets.length > 0) {
|
|
1542
|
+
const outcomes = new Map((dispatch?.huddleOutcomes ?? []).map((o) => [o.sessionName, o.dispatched]));
|
|
1543
|
+
for (const { session, key } of typingTargets) {
|
|
1544
|
+
const delivered = outcomes.get(session) ?? dispatch?.dispatched ?? false;
|
|
1545
|
+
if (delivered)
|
|
1546
|
+
await this.deps.typing.setPhase(key, 'typing');
|
|
1547
|
+
else
|
|
1548
|
+
await this.deps.typing.fail(key);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
this.logger.info('Slack team message routed', {
|
|
1552
|
+
teamId: mapping.teamId,
|
|
1553
|
+
slackChannel: `#${mapping.slackChannelName}`,
|
|
1554
|
+
mentions: resolved.mentions,
|
|
1555
|
+
...(mentionedElsewhere.length > 0 ? { mentionedElsewhere } : {}),
|
|
1556
|
+
unknown: resolved.unknown.map((u) => u.token),
|
|
1557
|
+
strategy: dispatch?.strategy ?? 'none',
|
|
1558
|
+
threaded: !!threadId,
|
|
1559
|
+
...(oneResponder
|
|
1462
1560
|
? {
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
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 ?? [],
|
|
1467
1565
|
}
|
|
1468
1566
|
: {}),
|
|
1469
1567
|
});
|
|
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);
|
|
1572
|
+
if (dispatch?.dispatched) {
|
|
1573
|
+
// Someone here holds it — including a follow-up in a thread we were
|
|
1574
|
+
// still waiting on.
|
|
1575
|
+
this.settleUnanswered(`${message.channelId}:${slackThreadTs}`);
|
|
1576
|
+
// The owner machine of a shared room (others deferred to it) runs the
|
|
1577
|
+
// one 90 s fallback: if nobody here answers, its lead is woken.
|
|
1578
|
+
const allOptional = (dispatch.huddleOutcomes ?? []).every((o) => o.responseMode !== 'required');
|
|
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);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
else if (!remoteAgent) {
|
|
1587
|
+
// Never a silent no-op: say why nobody got it.
|
|
1588
|
+
this.logger.warn('Slack room message reached nobody on this machine', {
|
|
1589
|
+
teamId: mapping.teamId,
|
|
1590
|
+
slackChannel: `#${mapping.slackChannelName}`,
|
|
1591
|
+
ts: message.ts,
|
|
1592
|
+
planned: planned ? [...planned.keys()] : null,
|
|
1593
|
+
failed: (dispatch?.huddleOutcomes ?? []).filter((o) => !o.dispatched).map((o) => o.sessionName),
|
|
1594
|
+
awakeHere: presence?.state.awakeHere ?? null,
|
|
1595
|
+
awakeElsewhere: presence?.state.awakeElsewhere ?? null,
|
|
1596
|
+
wakeWhenAllAsleep: presence?.state.wakeWhenAllAsleep ?? null,
|
|
1597
|
+
handoffTo,
|
|
1598
|
+
recipients,
|
|
1599
|
+
});
|
|
1600
|
+
// A message deferred to another machine is watched there, by its
|
|
1601
|
+
// owner, only — two machines running the fallback would answer twice.
|
|
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);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
return { mapping, message: persisted, mentions: resolved.mentions, dispatch };
|
|
1470
1616
|
}
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
});
|
|
1617
|
+
catch (err) {
|
|
1618
|
+
routeGuard?.fail(err);
|
|
1619
|
+
throw err;
|
|
1475
1620
|
}
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
}
|
|
1479
|
-
autoWatch?.delivered(deliveredSessions(dispatch));
|
|
1480
|
-
// Dispatch is done: each placeholder now reflects whether its agent
|
|
1481
|
-
// actually holds the message.
|
|
1482
|
-
if (this.deps.typing && typingTargets.length > 0) {
|
|
1483
|
-
const outcomes = new Map((dispatch?.huddleOutcomes ?? []).map((o) => [o.sessionName, o.dispatched]));
|
|
1484
|
-
for (const { session, key } of typingTargets) {
|
|
1485
|
-
const delivered = outcomes.get(session) ?? dispatch?.dispatched ?? false;
|
|
1486
|
-
if (delivered)
|
|
1487
|
-
await this.deps.typing.setPhase(key, 'typing');
|
|
1488
|
-
else
|
|
1489
|
-
await this.deps.typing.fail(key);
|
|
1490
|
-
}
|
|
1621
|
+
finally {
|
|
1622
|
+
routeGuard?.disarm();
|
|
1491
1623
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
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() : {}) } };
|
|
1511
1720
|
}
|
|
1721
|
+
return ownsRoom ? base : deferToRoomOwner;
|
|
1512
1722
|
}
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
slackChannel: `#${mapping.slackChannelName}`,
|
|
1518
|
-
ts: message.ts,
|
|
1519
|
-
planned: planned ? [...planned.keys()] : null,
|
|
1520
|
-
failed: (dispatch?.huddleOutcomes ?? []).filter((o) => !o.dispatched).map((o) => o.sessionName),
|
|
1521
|
-
awakeHere: presence?.state.awakeHere ?? null,
|
|
1522
|
-
awakeElsewhere: presence?.state.awakeElsewhere ?? null,
|
|
1523
|
-
wakeWhenAllAsleep: presence?.state.wakeWhenAllAsleep ?? null,
|
|
1524
|
-
handoffTo,
|
|
1525
|
-
recipients,
|
|
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),
|
|
1526
1727
|
});
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
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;
|
|
1531
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;
|
|
1532
1870
|
}
|
|
1533
|
-
return { mapping, message: persisted, mentions: resolved.mentions, dispatch };
|
|
1534
1871
|
}
|
|
1535
1872
|
/**
|
|
1536
1873
|
* Hold an owner's room message that reached nobody here until an agent
|
|
@@ -1541,13 +1878,13 @@ export class SlackTeamChannelService {
|
|
|
1541
1878
|
* @param persisted - The huddle row it was recorded as
|
|
1542
1879
|
* @param threadId - Huddle thread the replies land in
|
|
1543
1880
|
*/
|
|
1544
|
-
watchUnanswered(message, mapping, persisted, threadId, recipients = []) {
|
|
1881
|
+
watchUnanswered(message, mapping, persisted, threadId, recipients = [], heldBy = [], noteOnly = false) {
|
|
1545
1882
|
const threadTs = message.threadTs || message.ts;
|
|
1546
1883
|
const key = `${message.channelId}:${threadTs}`;
|
|
1547
1884
|
this.settleUnanswered(key);
|
|
1548
1885
|
const timer = setTimeout(() => {
|
|
1549
1886
|
void this.runUnansweredFallback(key);
|
|
1550
|
-
}, 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);
|
|
1551
1888
|
timer.unref?.();
|
|
1552
1889
|
this.unanswered.set(key, {
|
|
1553
1890
|
message,
|
|
@@ -1557,6 +1894,8 @@ export class SlackTeamChannelService {
|
|
|
1557
1894
|
threadId,
|
|
1558
1895
|
messageId: persisted.id,
|
|
1559
1896
|
recipients,
|
|
1897
|
+
...(heldBy.length > 0 ? { heldBy } : {}),
|
|
1898
|
+
...(noteOnly ? { noteOnly: true } : {}),
|
|
1560
1899
|
timer,
|
|
1561
1900
|
});
|
|
1562
1901
|
}
|
|
@@ -1565,9 +1904,156 @@ export class SlackTeamChannelService {
|
|
|
1565
1904
|
const pending = this.unanswered.get(key);
|
|
1566
1905
|
if (!pending)
|
|
1567
1906
|
return;
|
|
1568
|
-
|
|
1907
|
+
if (pending.timer)
|
|
1908
|
+
clearTimeout(pending.timer);
|
|
1569
1909
|
this.unanswered.delete(key);
|
|
1570
1910
|
}
|
|
1911
|
+
/**
|
|
1912
|
+
* Arm the route guard for an owner's room message that was just recorded
|
|
1913
|
+
* (crewly#1015 §7). `dispatchStarting` right before dispatch: it stops the
|
|
1914
|
+
* timer and answers false when a rescue already ran (the caller must not
|
|
1915
|
+
* dispatch then). `fail` when routing threw — a rescue only when dispatch
|
|
1916
|
+
* had not started. `disarm` when routing finished. Not at dispatch within
|
|
1917
|
+
* ROUTE_STALL_MS → the message is rescued.
|
|
1918
|
+
*
|
|
1919
|
+
* @param message - The inbound Slack message
|
|
1920
|
+
* @param mapping - Its room
|
|
1921
|
+
* @param persisted - The huddle row it was recorded as
|
|
1922
|
+
* @param threadId - Huddle thread the replies land in
|
|
1923
|
+
* @returns The guard
|
|
1924
|
+
*/
|
|
1925
|
+
armRouteGuard(message, mapping, persisted, threadId) {
|
|
1926
|
+
let done = false;
|
|
1927
|
+
let rescued = false;
|
|
1928
|
+
const placeholders = [];
|
|
1929
|
+
const rescue = (why, err) => {
|
|
1930
|
+
if (done)
|
|
1931
|
+
return;
|
|
1932
|
+
done = true;
|
|
1933
|
+
rescued = true;
|
|
1934
|
+
clearTimeout(timer);
|
|
1935
|
+
this.logger.error(why === 'stalled'
|
|
1936
|
+
? 'Owner room message routing has not finished — rescuing it with the unanswered-message fallback'
|
|
1937
|
+
: 'Owner room message routing failed — rescuing it with the unanswered-message fallback', {
|
|
1938
|
+
slackChannel: `#${mapping.slackChannelName}`,
|
|
1939
|
+
ts: message.ts,
|
|
1940
|
+
threaded: !!message.threadTs,
|
|
1941
|
+
handoff: message.handoffTo ?? null,
|
|
1942
|
+
...(err !== undefined ? { error: err instanceof Error ? err.message : String(err) } : {}),
|
|
1943
|
+
});
|
|
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', {
|
|
1955
|
+
ts: message.ts,
|
|
1956
|
+
error: rescueErr instanceof Error ? rescueErr.message : String(rescueErr),
|
|
1957
|
+
}));
|
|
1958
|
+
};
|
|
1959
|
+
const timer = setTimeout(() => rescue('stalled'), this.routeStallMs());
|
|
1960
|
+
timer.unref?.();
|
|
1961
|
+
return {
|
|
1962
|
+
disarm: () => {
|
|
1963
|
+
if (done)
|
|
1964
|
+
return;
|
|
1965
|
+
done = true;
|
|
1966
|
+
clearTimeout(timer);
|
|
1967
|
+
},
|
|
1968
|
+
// After dispatch started, a throw is not an undelivered message:
|
|
1969
|
+
// rescuing then could deliver it twice.
|
|
1970
|
+
fail: (err) => rescue('failed', err),
|
|
1971
|
+
rescued: () => rescued,
|
|
1972
|
+
notePlaceholder: (key) => {
|
|
1973
|
+
if (!rescued)
|
|
1974
|
+
placeholders.push(key);
|
|
1975
|
+
},
|
|
1976
|
+
dispatchStarting: () => {
|
|
1977
|
+
if (rescued)
|
|
1978
|
+
return false;
|
|
1979
|
+
done = true;
|
|
1980
|
+
clearTimeout(timer);
|
|
1981
|
+
return true;
|
|
1982
|
+
},
|
|
1983
|
+
};
|
|
1984
|
+
}
|
|
1985
|
+
/**
|
|
1986
|
+
* Wait for a routing step at most ROUTE_STEP_TIMEOUT_MS; on timeout (or a
|
|
1987
|
+
* rejection) go on with `fallback` and say so. Only for steps before
|
|
1988
|
+
* dispatch that routing can do without (crewly#1015 §7).
|
|
1989
|
+
*
|
|
1990
|
+
* @param step - The step
|
|
1991
|
+
* @param fallback - Value used when it does not settle in time
|
|
1992
|
+
* @param label - For the log
|
|
1993
|
+
* @param message - The message being routed (for the log)
|
|
1994
|
+
* @returns The step's value, or the fallback
|
|
1995
|
+
*/
|
|
1996
|
+
async bounded(step, fallback, label, message) {
|
|
1997
|
+
let timer;
|
|
1998
|
+
const timeout = new Promise((resolve) => {
|
|
1999
|
+
timer = setTimeout(() => resolve({ timedOut: true }), this.windowMs(SLACK_TEAM_CHANNEL_CONSTANTS.ROUTE_STEP_TIMEOUT_ENV, SLACK_TEAM_CHANNEL_CONSTANTS.ROUTE_STEP_TIMEOUT_MS));
|
|
2000
|
+
timer.unref?.();
|
|
2001
|
+
});
|
|
2002
|
+
try {
|
|
2003
|
+
const out = await Promise.race([step.then((value) => ({ value })), timeout]);
|
|
2004
|
+
if ('timedOut' in out) {
|
|
2005
|
+
this.logger.warn('A routing step did not finish in time — going on without it', { step: label, ts: message.ts });
|
|
2006
|
+
return fallback;
|
|
2007
|
+
}
|
|
2008
|
+
return out.value;
|
|
2009
|
+
}
|
|
2010
|
+
catch (err) {
|
|
2011
|
+
this.logger.warn('A routing step failed — going on without it', { step: label, ts: message.ts, error: err instanceof Error ? err.message : String(err) });
|
|
2012
|
+
return fallback;
|
|
2013
|
+
}
|
|
2014
|
+
finally {
|
|
2015
|
+
if (timer)
|
|
2016
|
+
clearTimeout(timer);
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
/** @returns How long routing may take before the guard rescues the message (env override for tests). */
|
|
2020
|
+
routeStallMs() {
|
|
2021
|
+
return this.windowMs(SLACK_TEAM_CHANNEL_CONSTANTS.ROUTE_STALL_ENV, SLACK_TEAM_CHANNEL_CONSTANTS.ROUTE_STALL_MS);
|
|
2022
|
+
}
|
|
2023
|
+
/**
|
|
2024
|
+
* Rescue an owner's room message whose routing stalled or failed: the
|
|
2025
|
+
* unanswered-message fallback runs at once (the room lead here is handed
|
|
2026
|
+
* the message). A message that was itself such a hand-off is not handed
|
|
2027
|
+
* over again: the owner is told in the thread.
|
|
2028
|
+
*
|
|
2029
|
+
* @param message - The inbound Slack message
|
|
2030
|
+
* @param mapping - Its room
|
|
2031
|
+
* @param persisted - The huddle row
|
|
2032
|
+
* @param threadId - Huddle thread the replies land in
|
|
2033
|
+
*/
|
|
2034
|
+
async rescueUnroutedMessage(message, mapping, persisted, threadId) {
|
|
2035
|
+
const threadTs = message.threadTs || message.ts;
|
|
2036
|
+
const key = `${message.channelId}:${threadTs}`;
|
|
2037
|
+
const pending = {
|
|
2038
|
+
// The hand-off does without the Slack thread context: reading it may
|
|
2039
|
+
// be what got stuck.
|
|
2040
|
+
message: { ...message, threadContext: undefined },
|
|
2041
|
+
chatChannelId: mapping.chatChannelId,
|
|
2042
|
+
slackChannelId: message.channelId,
|
|
2043
|
+
threadTs,
|
|
2044
|
+
threadId,
|
|
2045
|
+
messageId: persisted.id,
|
|
2046
|
+
recipients: [],
|
|
2047
|
+
note: SLACK_TEAM_CHANNEL_CONSTANTS.ROOM_ROUTE_STALLED_NOTE,
|
|
2048
|
+
};
|
|
2049
|
+
if (message.handoffTo) {
|
|
2050
|
+
await this.postUnansweredNote(pending, mapping);
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
this.settleUnanswered(key);
|
|
2054
|
+
this.unanswered.set(key, pending);
|
|
2055
|
+
await this.runUnansweredFallback(key);
|
|
2056
|
+
}
|
|
1571
2057
|
/**
|
|
1572
2058
|
* An agent spoke in a room — any message we were waiting on in that
|
|
1573
2059
|
* thread (or the room, for a top-level post) has been taken.
|
|
@@ -1599,7 +2085,43 @@ export class SlackTeamChannelService {
|
|
|
1599
2085
|
return;
|
|
1600
2086
|
this.unanswered.delete(key);
|
|
1601
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
|
+
}
|
|
1602
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
|
+
}
|
|
1603
2125
|
const lead = mapping ? await this.localRoomLead(mapping) : null;
|
|
1604
2126
|
if (mapping && lead && pending.recipients.includes(lead)) {
|
|
1605
2127
|
// The lead already holds it (delivered as optional): handing it over
|
|
@@ -1684,7 +2206,7 @@ export class SlackTeamChannelService {
|
|
|
1684
2206
|
await this.deps.slack.sendMessage({
|
|
1685
2207
|
channelId: pending.slackChannelId,
|
|
1686
2208
|
threadTs: pending.threadTs,
|
|
1687
|
-
text: SLACK_TEAM_CHANNEL_CONSTANTS.ROOM_UNANSWERED_NOTE,
|
|
2209
|
+
text: pending.note ?? SLACK_TEAM_CHANNEL_CONSTANTS.ROOM_UNANSWERED_NOTE,
|
|
1688
2210
|
skipChatV2Mirror: true,
|
|
1689
2211
|
notAnAnswer: true,
|
|
1690
2212
|
...(botToken ? { botToken } : {}),
|
|
@@ -1714,7 +2236,7 @@ export class SlackTeamChannelService {
|
|
|
1714
2236
|
* @param remoteAgent - Authoring agent, when an agent wrote it
|
|
1715
2237
|
* @returns The ticket the message belongs to, or null
|
|
1716
2238
|
*/
|
|
1717
|
-
async intakeTicket(message, mapping, mentions, planned, handoffTo, remoteAgent) {
|
|
2239
|
+
async intakeTicket(message, mapping, mentions, planned, handoffTo, remoteAgent, delivered) {
|
|
1718
2240
|
try {
|
|
1719
2241
|
const intake = getTicketIntakeService();
|
|
1720
2242
|
if (!intake || remoteAgent)
|
|
@@ -1738,7 +2260,16 @@ export class SlackTeamChannelService {
|
|
|
1738
2260
|
userName: message.user?.realName || message.user?.name,
|
|
1739
2261
|
hasFiles: message.hasFiles,
|
|
1740
2262
|
ownerUserId: this.deps.getOwnerUserId?.() ?? null,
|
|
1741
|
-
}, '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);
|
|
1742
2273
|
return ticketOfOutcome(outcome);
|
|
1743
2274
|
}
|
|
1744
2275
|
catch (err) {
|
|
@@ -3006,6 +3537,28 @@ export class SlackTeamChannelService {
|
|
|
3006
3537
|
}
|
|
3007
3538
|
}
|
|
3008
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
|
+
}
|
|
3009
3562
|
function isMapping(value) {
|
|
3010
3563
|
const v = value;
|
|
3011
3564
|
return (!!v &&
|