cli-jaw 2.17.43 → 2.17.44
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/README.md +13 -2
- package/dist/bin/commands/slack.js +73 -7
- package/dist/bin/commands/slack.js.map +1 -1
- package/dist/server.js +46 -1
- package/dist/server.js.map +1 -1
- package/dist/src/agent/calendar-context.js +23 -0
- package/dist/src/agent/calendar-context.js.map +1 -0
- package/dist/src/agent/claude-runtime-run.js +10 -1
- package/dist/src/agent/claude-runtime-run.js.map +1 -1
- package/dist/src/agent/cursor-acp-models.js +139 -0
- package/dist/src/agent/cursor-acp-models.js.map +1 -0
- package/dist/src/agent/events/cursor.js +28 -4
- package/dist/src/agent/events/cursor.js.map +1 -1
- package/dist/src/agent/lifecycle-handler.js +8 -2
- package/dist/src/agent/lifecycle-handler.js.map +1 -1
- package/dist/src/agent/native-runtime-run.js +22 -1
- package/dist/src/agent/native-runtime-run.js.map +1 -1
- package/dist/src/agent/runtime/acp/config.js +76 -11
- package/dist/src/agent/runtime/acp/config.js.map +1 -1
- package/dist/src/agent/runtime/acp/cursor-session.js +6 -1
- package/dist/src/agent/runtime/acp/cursor-session.js.map +1 -1
- package/dist/src/agent/runtime/liveness.js +24 -0
- package/dist/src/agent/runtime/liveness.js.map +1 -0
- package/dist/src/agent/runtime/start-failure.js +61 -0
- package/dist/src/agent/runtime/start-failure.js.map +1 -0
- package/dist/src/agent/spawn/queue.js +11 -6
- package/dist/src/agent/spawn/queue.js.map +1 -1
- package/dist/src/agent/spawn.js +59 -17
- package/dist/src/agent/spawn.js.map +1 -1
- package/dist/src/cli/api-auth.js +6 -0
- package/dist/src/cli/api-auth.js.map +1 -1
- package/dist/src/manager/design/watcher.js +10 -2
- package/dist/src/manager/design/watcher.js.map +1 -1
- package/dist/src/memory/heartbeat.js +6 -0
- package/dist/src/memory/heartbeat.js.map +1 -1
- package/dist/src/messaging/channel-validate.js +5 -3
- package/dist/src/messaging/channel-validate.js.map +1 -1
- package/dist/src/messaging/redact.js +30 -8
- package/dist/src/messaging/redact.js.map +1 -1
- package/dist/src/messaging/send.js +7 -0
- package/dist/src/messaging/send.js.map +1 -1
- package/dist/src/orchestrator/collect.js +5 -3
- package/dist/src/orchestrator/collect.js.map +1 -1
- package/dist/src/orchestrator/gateway.js +10 -3
- package/dist/src/orchestrator/gateway.js.map +1 -1
- package/dist/src/orchestrator/pipeline.js +21 -9
- package/dist/src/orchestrator/pipeline.js.map +1 -1
- package/dist/src/orchestrator/request-registry.js +2 -0
- package/dist/src/orchestrator/request-registry.js.map +1 -1
- package/dist/src/prompt/builder.js +74 -6
- package/dist/src/prompt/builder.js.map +1 -1
- package/dist/src/prompt/templates/a1-system.md +22 -5
- package/dist/src/routes/messaging.js +105 -27
- package/dist/src/routes/messaging.js.map +1 -1
- package/dist/src/routes/settings.js +20 -8
- package/dist/src/routes/settings.js.map +1 -1
- package/dist/src/routes/slack-tools.js +176 -0
- package/dist/src/routes/slack-tools.js.map +1 -0
- package/dist/src/slack/action-rate.js +43 -0
- package/dist/src/slack/action-rate.js.map +1 -0
- package/dist/src/slack/action-runtime.js +243 -0
- package/dist/src/slack/action-runtime.js.map +1 -0
- package/dist/src/slack/action-store.js +84 -0
- package/dist/src/slack/action-store.js.map +1 -0
- package/dist/src/slack/action-types.js +6 -0
- package/dist/src/slack/action-types.js.map +1 -0
- package/dist/src/slack/actions-canvas.js +108 -0
- package/dist/src/slack/actions-canvas.js.map +1 -0
- package/dist/src/slack/actions-channel.js +88 -0
- package/dist/src/slack/actions-channel.js.map +1 -0
- package/dist/src/slack/actions-interactions.js +204 -0
- package/dist/src/slack/actions-interactions.js.map +1 -0
- package/dist/src/slack/actions-lists.js +308 -0
- package/dist/src/slack/actions-lists.js.map +1 -0
- package/dist/src/slack/actions-message.js +117 -0
- package/dist/src/slack/actions-message.js.map +1 -0
- package/dist/src/slack/actions-resources.js +62 -0
- package/dist/src/slack/actions-resources.js.map +1 -0
- package/dist/src/slack/actions-rts.js +88 -0
- package/dist/src/slack/actions-rts.js.map +1 -0
- package/dist/src/slack/actions-schedule.js +214 -0
- package/dist/src/slack/actions-schedule.js.map +1 -0
- package/dist/src/slack/actions.js +17 -0
- package/dist/src/slack/actions.js.map +1 -0
- package/dist/src/slack/api.js +56 -9
- package/dist/src/slack/api.js.map +1 -1
- package/dist/src/slack/blocks.js +162 -0
- package/dist/src/slack/blocks.js.map +1 -0
- package/dist/src/slack/bot.js +562 -378
- package/dist/src/slack/bot.js.map +1 -1
- package/dist/src/slack/commands.js +24 -5
- package/dist/src/slack/commands.js.map +1 -1
- package/dist/src/slack/context.js +15 -4
- package/dist/src/slack/context.js.map +1 -1
- package/dist/src/slack/conversation.js +135 -33
- package/dist/src/slack/conversation.js.map +1 -1
- package/dist/src/slack/events.js +41 -17
- package/dist/src/slack/events.js.map +1 -1
- package/dist/src/slack/format.js +181 -0
- package/dist/src/slack/format.js.map +1 -1
- package/dist/src/slack/history.js +133 -8
- package/dist/src/slack/history.js.map +1 -1
- package/dist/src/slack/ingress.js +26 -9
- package/dist/src/slack/ingress.js.map +1 -1
- package/dist/src/slack/interaction-store.js +56 -0
- package/dist/src/slack/interaction-store.js.map +1 -0
- package/dist/src/slack/manifest.js +3 -0
- package/dist/src/slack/manifest.js.map +1 -1
- package/dist/src/slack/message-content.js +132 -0
- package/dist/src/slack/message-content.js.map +1 -0
- package/dist/src/slack/message.js +58 -0
- package/dist/src/slack/message.js.map +1 -0
- package/dist/src/slack/notice-transport.js +49 -19
- package/dist/src/slack/notice-transport.js.map +1 -1
- package/dist/src/slack/operator-auth.js +62 -0
- package/dist/src/slack/operator-auth.js.map +1 -0
- package/dist/src/slack/progress-activity.js +210 -0
- package/dist/src/slack/progress-activity.js.map +1 -0
- package/dist/src/slack/progress-detail.js +473 -0
- package/dist/src/slack/progress-detail.js.map +1 -0
- package/dist/src/slack/progress-files.js +70 -0
- package/dist/src/slack/progress-files.js.map +1 -0
- package/dist/src/slack/progress-lifecycle.js +223 -0
- package/dist/src/slack/progress-lifecycle.js.map +1 -0
- package/dist/src/slack/progress-restore.js +81 -0
- package/dist/src/slack/progress-restore.js.map +1 -0
- package/dist/src/slack/progress.js +325 -143
- package/dist/src/slack/progress.js.map +1 -1
- package/dist/src/slack/quote.js +227 -0
- package/dist/src/slack/quote.js.map +1 -0
- package/dist/src/slack/render-features.js +105 -0
- package/dist/src/slack/render-features.js.map +1 -0
- package/dist/src/slack/reply-delivery.js +110 -0
- package/dist/src/slack/reply-delivery.js.map +1 -0
- package/dist/src/slack/rts-output-store.js +143 -0
- package/dist/src/slack/rts-output-store.js.map +1 -0
- package/dist/src/slack/scope-status.js +17 -3
- package/dist/src/slack/scope-status.js.map +1 -1
- package/dist/src/slack/search-quote.js +70 -0
- package/dist/src/slack/search-quote.js.map +1 -0
- package/dist/src/slack/search.js +54 -0
- package/dist/src/slack/search.js.map +1 -0
- package/dist/src/slack/send-handler.js +11 -7
- package/dist/src/slack/send-handler.js.map +1 -1
- package/dist/src/slack/send-only-client.js +116 -79
- package/dist/src/slack/send-only-client.js.map +1 -1
- package/dist/src/slack/table-content.js +253 -0
- package/dist/src/slack/table-content.js.map +1 -0
- package/dist/src/slack/table-verification.js +64 -0
- package/dist/src/slack/table-verification.js.map +1 -0
- package/dist/src/slack/task-input.js +55 -0
- package/dist/src/slack/task-input.js.map +1 -0
- package/dist/src/slack/tool-access.js +127 -0
- package/dist/src/slack/tool-access.js.map +1 -0
- package/dist/src/slack/tool-capabilities.js +47 -0
- package/dist/src/slack/tool-capabilities.js.map +1 -0
- package/dist/src/slack/tool-context.js +91 -0
- package/dist/src/slack/tool-context.js.map +1 -0
- package/dist/src/slack/verified-workspace.js +17 -6
- package/dist/src/slack/verified-workspace.js.map +1 -1
- package/dist/src/trace/redact.js +2 -1
- package/dist/src/trace/redact.js.map +1 -1
- package/package.json +1 -1
- package/public/dist/.vite/manifest.json +102 -102
- package/public/dist/assets/{Agent-CQ4EBgY8.js → Agent-CNad_A6t.js} +1 -1
- package/public/dist/assets/{ChannelEnablementControl-JsYvNMlM.js → ChannelEnablementControl-DwUDKjX9.js} +4 -4
- package/public/dist/assets/{ChannelSetupEntry-BpyRWEUE.js → ChannelSetupEntry-DH0AuNNX.js} +1 -1
- package/public/dist/assets/{ChannelsDiscord-DNfvz1jn.js → ChannelsDiscord-7NmoTjR7.js} +1 -1
- package/public/dist/assets/{ChannelsSlack-DzpxIdgW.js → ChannelsSlack-Ba0ax6iT.js} +1 -1
- package/public/dist/assets/{ChannelsTelegram-CUhFc5s6.js → ChannelsTelegram-DKA9atf2.js} +1 -1
- package/public/dist/assets/{app-Q0prKOFx.js → app-9OQNfmqw.js} +4 -4
- package/public/dist/assets/{bgtask-badge-DAcWaUfS.js → bgtask-badge-BvxHqMGi.js} +1 -1
- package/public/dist/assets/bgtask-badge-DeSZJGNN.js +1 -0
- package/public/dist/assets/{employees-CWS3Juca.js → employees-CkUqN4oh.js} +1 -1
- package/public/dist/assets/{iframe-renderer-DCDKBMb7.js → iframe-renderer-C2gchtOj.js} +2 -2
- package/public/dist/assets/iframe-renderer-DfvACXIn.js +1 -0
- package/public/dist/assets/{manager-v6GYfoPB.js → manager-rb5X-4DJ.js} +1 -1
- package/public/dist/assets/{memory-DhNdKZLO.js → memory-BiWzpoBq.js} +1 -1
- package/public/dist/assets/memory-Ckp1Eq5a.js +1 -0
- package/public/dist/assets/message-history-Bv0l_t7O.js +1 -0
- package/public/dist/assets/{message-history-DbRTl1P0.js → message-history-CuFefS02.js} +1 -1
- package/public/dist/assets/{render-DP8SJtdZ.js → render-DUGqq9Mu.js} +1 -1
- package/public/dist/assets/{settings-85n9NjvX.js → settings-BqUHavbH.js} +1 -1
- package/public/dist/assets/settings-C81Kf9uX.js +1 -0
- package/public/dist/assets/{settings-C9_D-pn0.js → settings-DQ1fLygB.js} +31 -30
- package/public/dist/assets/{settings-core-BcVD2FgV.js → settings-core-CzdXfoOK.js} +1 -1
- package/public/dist/assets/settings-core-DO_5M7Lv.js +1 -0
- package/public/dist/assets/{settings-embedding-BSpq785l.js → settings-embedding-BAKFrVLV.js} +1 -1
- package/public/dist/assets/{settings-types-B09BmpYe.js → settings-types-D3cd6SWy.js} +1 -1
- package/public/dist/assets/{sidebar-BVhLb5Ft.js → sidebar-CvGbp9x5.js} +3 -3
- package/public/dist/assets/skills-DFpPmMUR.js +1 -0
- package/public/dist/assets/{skills-X7qxmHka.js → skills-abLMqY5_.js} +1 -1
- package/public/dist/assets/slash-commands-BU58xYih.js +1 -0
- package/public/dist/assets/{slash-commands-BnDH4IU_.js → slash-commands-D8o9pqOS.js} +1 -1
- package/public/dist/assets/{trace-drawer-DasaS1z4.js → trace-drawer-C6RvkUAd.js} +1 -1
- package/public/dist/assets/{ui-BtiBTXng.js → ui-Bxapl_Eu.js} +1 -1
- package/public/dist/assets/ui-CUB4iwz3.js +1 -0
- package/public/dist/index.html +1 -1
- package/public/dist/manager/index.html +1 -1
- package/public/dist/settings/index.html +1 -1
- package/public/js/features/settings-cli-status.ts +11 -1
- package/public/js/features/settings-types.ts +21 -0
- package/public/locales/en.json +34 -1
- package/public/locales/ja.json +34 -1
- package/public/locales/ko.json +34 -1
- package/public/locales/zh.json +34 -1
- package/scripts/check-native-load.cjs +17 -4
- package/scripts/release-gates.mjs +84 -3
- package/scripts/service-artifact.mjs +157 -0
- package/scripts/service-shell/zsh/.zlogin +23 -0
- package/scripts/service-shell/zsh/.zlogout +23 -0
- package/scripts/service-shell/zsh/.zprofile +23 -0
- package/scripts/service-shell/zsh/.zshenv +23 -0
- package/scripts/service-shell/zsh/.zshrc +23 -0
- package/public/dist/assets/bgtask-badge-DHA2OQm8.js +0 -1
- package/public/dist/assets/iframe-renderer-RpvBly2x.js +0 -1
- package/public/dist/assets/memory-4YH8sz0L.js +0 -1
- package/public/dist/assets/message-history-lVcLpAw8.js +0 -1
- package/public/dist/assets/settings--EGy3_B9.js +0 -1
- package/public/dist/assets/settings-core-8wBl1mg-.js +0 -1
- package/public/dist/assets/skills-6GFkHTlQ.js +0 -1
- package/public/dist/assets/slash-commands-qu8fPAZ3.js +0 -1
- package/public/dist/assets/ui-BucJ1zMA.js +0 -1
package/dist/src/slack/bot.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { verifiedSlackWorkspace } from './verified-workspace.js';
|
|
2
|
+
import { slackCredentialKey } from './tool-context.js';
|
|
3
|
+
import { isSlackMention } from './events.js';
|
|
1
4
|
// ─── Slack Bot ───────────────────────────────────────
|
|
2
5
|
// Slack transport implementation for the cli-jaw messaging runtime.
|
|
3
6
|
// Mirrors src/discord/bot.ts structurally: init/shutdown lifecycle, an inbound
|
|
@@ -14,10 +17,13 @@ import { isResetIntent } from '../orchestrator/pipeline.js';
|
|
|
14
17
|
import { isContinueIntent } from '../orchestrator/parser.js';
|
|
15
18
|
import { setLastActiveTarget, setLatestSeenTarget, getLastActiveTarget, revokeMessagingTransport, startMessagingTransport, transportStarted, transportNotStarted, } from '../messaging/runtime.js';
|
|
16
19
|
import { slackTargetFromId, resolveSlackThreadPlacement } from '../messaging/slack-target.js';
|
|
20
|
+
import { isRemoteTarget } from '../messaging/types.js';
|
|
21
|
+
import { sessionLanes } from '../orchestrator/session-lanes.js';
|
|
22
|
+
import { createSlackReplyDeliveryLedger } from './reply-delivery.js';
|
|
17
23
|
import { buildMediaPromptMany } from '../agent/spawn.js';
|
|
18
|
-
import { addSlackReaction, describeSlackError, removeSlackReaction, slackApi,
|
|
24
|
+
import { addSlackReaction, describeSlackError, removeSlackReaction, slackApi, } from './api.js';
|
|
19
25
|
import { createAckHandle, resolveAckConfig, shouldAck, SLACK_ACK_DEFAULTS, } from '../messaging/ack-reaction.js';
|
|
20
|
-
import {
|
|
26
|
+
import { QueueNoticeRegistry } from '../messaging/queue-notice.js';
|
|
21
27
|
import { OutboundSendRegistry } from '../messaging/outbound-lifecycle.js';
|
|
22
28
|
import { recordSlackScopeObservation, getSlackScopeStatus, describeSlackScopeGaps, resetSlackScopeStatus, } from './scope-status.js';
|
|
23
29
|
import { HELLO_DEADLINE_MS, SlackSocketClient } from './socket.js';
|
|
@@ -26,16 +32,16 @@ import { runSlackAutoJoin, mergeSlackAutoJoin } from './auto-join.js';
|
|
|
26
32
|
import { createHash } from 'node:crypto';
|
|
27
33
|
import { admitIngress, getIngressJournal } from '../messaging/durable-ingress.js';
|
|
28
34
|
import { getQueueNoticeStore } from '../messaging/queue-notice-store.js';
|
|
29
|
-
import { restoreQueueNotices } from '../messaging/queue-notice-restore.js';
|
|
30
35
|
import { createSlackNoticeTransport } from './notice-transport.js';
|
|
31
36
|
import { currentGenerationForEnvelope } from '../messaging/ingress-generation.js';
|
|
32
37
|
import { slackInboundEnvelope } from '../messaging/inbound-envelope.js';
|
|
33
38
|
import { readSlackAllowlist, resolveEventText, shouldAttachSlack, shouldProcessSlackEvent } from './events.js';
|
|
34
39
|
import { markThreadParticipated, threadParticipationKind, claimThreadPrefetch, commitThreadPrefetch, releaseThreadPrefetch, resetThreadPrefetchClaims, } from './thread-tracker.js';
|
|
35
40
|
import { sendSlackText, getSlackSendClient } from './send-only-client.js';
|
|
36
|
-
import {
|
|
41
|
+
import { createSlackProgressLifecycle } from './progress-lifecycle.js';
|
|
42
|
+
import { createSlackProgressRestorer } from './progress-restore.js';
|
|
37
43
|
import { createSlackForwarder, relaySlackImages } from './forwarder.js';
|
|
38
|
-
import { nextDeliverySeq,
|
|
44
|
+
import { nextDeliverySeq, wasSelfDelivered } from '../messaging/turn-delivery.js';
|
|
39
45
|
import { shouldSkipForwarding } from '../messaging/forwarder-origin.js';
|
|
40
46
|
import { handleSlackSlashCommand } from './commands.js';
|
|
41
47
|
import { logErrorText, redactOutboundText } from '../messaging/redact.js';
|
|
@@ -72,7 +78,32 @@ const pendingQueueRequestIds = new Set();
|
|
|
72
78
|
* turn that never answered gets its notice rewritten rather than left claiming
|
|
73
79
|
* the agent is still working on it.
|
|
74
80
|
*/
|
|
75
|
-
const
|
|
81
|
+
const slackProgressRegistry = new QueueNoticeRegistry();
|
|
82
|
+
const slackProgressSealers = new Set();
|
|
83
|
+
const liveSlackProgressRequestIds = new Set();
|
|
84
|
+
let slackStopping = false;
|
|
85
|
+
const SLACK_PROGRESS_DRAIN_MS = 1500;
|
|
86
|
+
const SLACK_QUEUE_WAIT_MS = 300000;
|
|
87
|
+
const SLACK_RUN_IDLE_MS = 1200000;
|
|
88
|
+
function registerSlackProgressTeardown(requestId, seal, drain) {
|
|
89
|
+
slackProgressSealers.add(seal);
|
|
90
|
+
liveSlackProgressRequestIds.add(requestId);
|
|
91
|
+
const unregister = slackProgressRegistry.add(drain);
|
|
92
|
+
return () => {
|
|
93
|
+
unregister();
|
|
94
|
+
slackProgressSealers.delete(seal);
|
|
95
|
+
liveSlackProgressRequestIds.delete(requestId);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const slackProgressRestorer = createSlackProgressRestorer({
|
|
99
|
+
getStore: getQueueNoticeStore,
|
|
100
|
+
getToken: () => getSlackSendClient().token,
|
|
101
|
+
getGeneration: () => lifecycleGeneration,
|
|
102
|
+
isLive: id => pendingQueueRequestIds.has(id) || liveSlackProgressRequestIds.has(id),
|
|
103
|
+
getLocale: currentLocale,
|
|
104
|
+
registerDrain: drain => slackProgressRegistry.add(drain),
|
|
105
|
+
onError: () => log.info('[slack:progress] restore incomplete'),
|
|
106
|
+
});
|
|
76
107
|
/**
|
|
77
108
|
* In-flight ANSWER sends (#417). The notice registry above cancels cleanup;
|
|
78
109
|
* this one cancels the answer body, chunk-retry sleeps, and image uploads —
|
|
@@ -85,12 +116,6 @@ const slackOutboundRegistry = new OutboundSendRegistry();
|
|
|
85
116
|
* the running apply can still be in flight ahead of both.
|
|
86
117
|
*/
|
|
87
118
|
const SLACK_ACK_TIMEOUT_MS = 2500;
|
|
88
|
-
/**
|
|
89
|
-
* Long enough for the worst honest chain — running apply, terminal remove,
|
|
90
|
-
* terminal apply, plus one notice call — so a slow-but-working Slack finishes
|
|
91
|
-
* inside the deadline instead of being cut off by it.
|
|
92
|
-
*/
|
|
93
|
-
const SLACK_NOTICE_DRAIN_MS = SLACK_CLEANUP_TIMEOUT_MS + SLACK_ACK_TIMEOUT_MS * 2;
|
|
94
119
|
/** Live ack config, re-read per turn so a settings change needs no restart. */
|
|
95
120
|
function slackAckConfig() {
|
|
96
121
|
return resolveAckConfig(settings["slack"]?.ack, SLACK_ACK_DEFAULTS);
|
|
@@ -169,42 +194,28 @@ function closeSlackNoticeRecord(requestId) {
|
|
|
169
194
|
* delete the notice message and drop the record. Best-effort like every other
|
|
170
195
|
* durable-notice touch; the answer is already out.
|
|
171
196
|
*/
|
|
172
|
-
async function closeSlackNoticeAsAnsweredByRequestId(requestId) {
|
|
197
|
+
async function closeSlackNoticeAsAnsweredByRequestId(requestId, token, signal, generation) {
|
|
198
|
+
const current = () => !signal.aborted && generation === lifecycleGeneration && !slackStopping;
|
|
173
199
|
try {
|
|
200
|
+
if (!current())
|
|
201
|
+
return;
|
|
174
202
|
const record = getQueueNoticeStore()?.findByRequestId(requestId);
|
|
175
203
|
if (!record)
|
|
176
204
|
return;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
await createSlackNoticeTransport(token, record.target.targetId, record.messageId)
|
|
180
|
-
.delete();
|
|
205
|
+
if (record.messageId) {
|
|
206
|
+
await createSlackNoticeTransport(token, record.target.targetId, record.messageId).delete(signal);
|
|
181
207
|
}
|
|
182
|
-
|
|
208
|
+
if (current())
|
|
209
|
+
closeSlackNoticeRecord(requestId);
|
|
183
210
|
}
|
|
184
211
|
catch (e) {
|
|
185
212
|
log.info('[slack:queue-notice] answered-close failed', logErrorText(e));
|
|
186
213
|
}
|
|
187
214
|
}
|
|
188
|
-
/**
|
|
189
|
-
* Rewrite notices left behind by a previous run.
|
|
190
|
-
*
|
|
191
|
-
* Called once the token is known, because the transport needs it — a record whose
|
|
192
|
-
* transport cannot be built yet is kept rather than closed (#418).
|
|
193
|
-
*/
|
|
215
|
+
/** Restore only recorded status messages; never infer a finished execution. */
|
|
194
216
|
export async function restoreSlackQueueNotices() {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
return;
|
|
198
|
-
const token = getSlackSendClient().token;
|
|
199
|
-
await restoreQueueNotices({
|
|
200
|
-
store,
|
|
201
|
-
channel: 'slack',
|
|
202
|
-
expiredText: t('tg.queueExpired', {}, currentLocale()),
|
|
203
|
-
transport: (record) => (token
|
|
204
|
-
? createSlackNoticeTransport(token, record.target.targetId, record.messageId)
|
|
205
|
-
: null),
|
|
206
|
-
onError: (e) => log.info('[slack:queue-notice] restore failed', logErrorText(e)),
|
|
207
|
-
});
|
|
217
|
+
if (!slackStopping)
|
|
218
|
+
await slackProgressRestorer.restore();
|
|
208
219
|
}
|
|
209
220
|
/** Stand in for a live queued-reply listener without opening a socket. */
|
|
210
221
|
export function claimSlackQueueRequestForTest(requestId) {
|
|
@@ -369,30 +380,344 @@ function requiresNativeBodyDelivery(data) {
|
|
|
369
380
|
return (data['runtimeFinality'] === 'present' || data['runtimeFinality'] === 'absent')
|
|
370
381
|
&& (data['runtimeStatus'] === 'done' || data['runtimeStatus'] === 'error' || data['runtimeStatus'] === 'stopped');
|
|
371
382
|
}
|
|
383
|
+
function bodyProgressOutcome(data, delivered, latched, stopping) {
|
|
384
|
+
const native = requiresNativeBodyDelivery(data);
|
|
385
|
+
if ((native && data['runtimeStatus'] === 'stopped') || (!native && data['executionInterrupted'] === true) || latched === 'cancelled')
|
|
386
|
+
return 'cancelled';
|
|
387
|
+
if (data['collectionFailure'] === 'timeout')
|
|
388
|
+
return 'expired';
|
|
389
|
+
if ((native && data['runtimeStatus'] === 'error') || (!native && data['executionFailed'] === true)
|
|
390
|
+
|| data['collectionFailure'] === 'error' || (!native && data['error'] === true) || latched === 'error')
|
|
391
|
+
return 'error';
|
|
392
|
+
if (!delivered)
|
|
393
|
+
return stopping ? 'expired' : 'error';
|
|
394
|
+
return 'complete';
|
|
395
|
+
}
|
|
396
|
+
const slackReplyDelivery = createSlackReplyDeliveryLedger();
|
|
397
|
+
const activeSlackReplyTrackers = new Map();
|
|
398
|
+
let slackReplyAdmissionDepth = 0;
|
|
399
|
+
const pendingSteerContexts = new Map();
|
|
400
|
+
const SLACK_PENDING_STEER_MAX = 256;
|
|
401
|
+
const SLACK_PENDING_STEER_TTL_MS = 300_000;
|
|
402
|
+
function matchesSlackReply(options, data) {
|
|
403
|
+
const target = data['target'];
|
|
404
|
+
return data['requestId'] === options.requestId
|
|
405
|
+
&& (data['scope'] === undefined || data['scope'] === options.session.scope)
|
|
406
|
+
&& (data['sessionId'] === undefined || data['sessionId'] === options.session.chatSessionId)
|
|
407
|
+
&& (data['origin'] === undefined || data['origin'] === 'slack')
|
|
408
|
+
&& (data['remoteKey'] === undefined || data['remoteKey'] === options.session.remoteKey)
|
|
409
|
+
&& (target === undefined || (isRemoteTarget(target) && target.channel === 'slack'
|
|
410
|
+
&& target.targetId === options.target.targetId && target.threadId === options.target.threadId
|
|
411
|
+
&& target.guildId === options.target.guildId && target.targetKind === options.target.targetKind
|
|
412
|
+
&& target.peerKind === options.target.peerKind && target.parentTargetId === options.target.parentTargetId));
|
|
413
|
+
}
|
|
414
|
+
function rememberPendingSteer(options) {
|
|
415
|
+
if (options.signal.aborted || slackStopping || options.generation !== lifecycleGeneration)
|
|
416
|
+
return;
|
|
417
|
+
pendingSteerContexts.get(options.requestId)?.dispose();
|
|
418
|
+
while (pendingSteerContexts.size >= SLACK_PENDING_STEER_MAX)
|
|
419
|
+
pendingSteerContexts.values().next().value.dispose();
|
|
420
|
+
const dispose = () => {
|
|
421
|
+
clearTimeout(timer);
|
|
422
|
+
options.signal.removeEventListener('abort', dispose);
|
|
423
|
+
slackProgressSealers.delete(dispose);
|
|
424
|
+
if (pendingSteerContexts.get(options.requestId)?.dispose === dispose)
|
|
425
|
+
pendingSteerContexts.delete(options.requestId);
|
|
426
|
+
};
|
|
427
|
+
const timer = setTimeout(dispose, SLACK_PENDING_STEER_TTL_MS);
|
|
428
|
+
timer.unref?.();
|
|
429
|
+
pendingSteerContexts.set(options.requestId, { options, dispose });
|
|
430
|
+
options.signal.addEventListener('abort', dispose, { once: true });
|
|
431
|
+
slackProgressSealers.add(dispose);
|
|
432
|
+
}
|
|
433
|
+
function observeSlackReplyControl(type, raw) {
|
|
434
|
+
if (!['steer_started', 'queue_update', 'queued_run_started', 'request_settled'].includes(type))
|
|
435
|
+
return;
|
|
436
|
+
const requestId = raw['requestId'];
|
|
437
|
+
if (typeof requestId !== 'string' || !requestId || slackStopping)
|
|
438
|
+
return;
|
|
439
|
+
// Copy control identity only; an inline producer may precede admission return.
|
|
440
|
+
const data = {};
|
|
441
|
+
for (const key of ['requestId', 'origin', 'scope', 'sessionId', 'remoteKey', 'mode', 'outcome'])
|
|
442
|
+
data[key] = raw[key];
|
|
443
|
+
if (raw['target'] !== undefined)
|
|
444
|
+
data['target'] = isRemoteTarget(raw['target']) ? { ...raw['target'] } : null;
|
|
445
|
+
const actualStart = type === 'queued_run_started' || (type === 'steer_started' && data['mode'] === 'restart');
|
|
446
|
+
const anchor = actualStart ? nextDeliverySeq() : undefined;
|
|
447
|
+
const generation = lifecycleGeneration;
|
|
448
|
+
const active = activeSlackReplyTrackers.get(requestId);
|
|
449
|
+
if (active && matchesSlackReply(active.options, data) && anchor !== undefined)
|
|
450
|
+
active.start(anchor);
|
|
451
|
+
// Boot/orphan queue starts have no admission callback to wait for. Keep their
|
|
452
|
+
// proof synchronous so an immediate completion cannot claim before the start.
|
|
453
|
+
if (!active && slackReplyAdmissionDepth === 0 && type === 'queued_run_started'
|
|
454
|
+
&& data['origin'] === 'slack' && isRemoteTarget(data['target']) && data['target'].channel === 'slack'
|
|
455
|
+
&& typeof data['scope'] === 'string') {
|
|
456
|
+
const pending = pendingSteerContexts.get(requestId);
|
|
457
|
+
if (!pending || matchesSlackReply(pending.options, data))
|
|
458
|
+
slackReplyDelivery.started(requestId, data['target'], data['scope'], anchor);
|
|
459
|
+
}
|
|
460
|
+
queueMicrotask(() => {
|
|
461
|
+
if (slackStopping || generation !== lifecycleGeneration)
|
|
462
|
+
return;
|
|
463
|
+
const pending = pendingSteerContexts.get(requestId);
|
|
464
|
+
if (!pending) {
|
|
465
|
+
const tracker = activeSlackReplyTrackers.get(requestId);
|
|
466
|
+
if (tracker) {
|
|
467
|
+
if (anchor !== undefined && matchesSlackReply(tracker.options, data))
|
|
468
|
+
tracker.start(anchor);
|
|
469
|
+
}
|
|
470
|
+
else if (type === 'queued_run_started' && data['origin'] === 'slack'
|
|
471
|
+
&& isRemoteTarget(data['target']) && data['target'].channel === 'slack' && typeof data['scope'] === 'string') {
|
|
472
|
+
slackReplyDelivery.started(requestId, data['target'], data['scope'], anchor);
|
|
473
|
+
}
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (!matchesSlackReply(pending.options, data))
|
|
477
|
+
return;
|
|
478
|
+
const { options } = pending;
|
|
479
|
+
if (options.signal.aborted || options.generation !== lifecycleGeneration) {
|
|
480
|
+
pending.dispose();
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (type === 'request_settled') {
|
|
484
|
+
if (['steered', 'failed', 'cancelled', 'dropped'].includes(String(data['outcome']))) {
|
|
485
|
+
pending.dispose();
|
|
486
|
+
if (data['outcome'] !== 'steered')
|
|
487
|
+
void options.ack?.settle('failure');
|
|
488
|
+
}
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (type === 'steer_started' && ['native-input', 'cancel-reprompt'].includes(String(data['mode']))) {
|
|
492
|
+
pending.dispose();
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (!actualStart && type !== 'queue_update')
|
|
496
|
+
return;
|
|
497
|
+
pending.dispose();
|
|
498
|
+
if (anchor !== undefined)
|
|
499
|
+
slackReplyDelivery.started(requestId, options.target, options.session.scope, anchor);
|
|
500
|
+
trackSlackReply({ ...options, initialPhase: actualStart ? 'running' : 'queued' });
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
function trackSlackReply(options) {
|
|
504
|
+
const { token, target, requestId, session, locale, generation, signal, ack, recipientUserId, initialPhase } = options;
|
|
505
|
+
if (signal.aborted || slackStopping || generation !== lifecycleGeneration || activeSlackReplyTrackers.has(requestId))
|
|
506
|
+
return;
|
|
507
|
+
// A synchronous terminal may have reached the orphan owner before admission
|
|
508
|
+
// returned. Its attempted body is not proof this late observer may ACK success.
|
|
509
|
+
if (slackReplyDelivery.claimed(requestId, target)) {
|
|
510
|
+
void ack?.settle('failure');
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
slackReplyDelivery.remember(requestId, target, session.scope);
|
|
514
|
+
let started = initialPhase === 'running';
|
|
515
|
+
let observedAnchor = slackReplyDelivery.anchor(requestId, target);
|
|
516
|
+
let disposed = false;
|
|
517
|
+
let shutdownSealed = false;
|
|
518
|
+
let executionOutcome;
|
|
519
|
+
let timer;
|
|
520
|
+
let terminal = null;
|
|
521
|
+
let ackSettled = false;
|
|
522
|
+
let display;
|
|
523
|
+
const current = () => !shutdownSealed && !slackStopping && !signal.aborted && generation === lifecycleGeneration;
|
|
524
|
+
const matches = (data) => matchesSlackReply(options, data);
|
|
525
|
+
async function settleAck(outcome) {
|
|
526
|
+
if (ackSettled)
|
|
527
|
+
return;
|
|
528
|
+
ackSettled = true;
|
|
529
|
+
await ack?.settle(outcome);
|
|
530
|
+
}
|
|
531
|
+
function disposeListener() {
|
|
532
|
+
if (disposed)
|
|
533
|
+
return;
|
|
534
|
+
disposed = true;
|
|
535
|
+
if (timer)
|
|
536
|
+
clearTimeout(timer);
|
|
537
|
+
removeBroadcastListener(queueHandler);
|
|
538
|
+
activeSlackReplyTrackers.delete(requestId);
|
|
539
|
+
}
|
|
540
|
+
function claimTerminal(run, ownsBody = false) {
|
|
541
|
+
if (!terminal) {
|
|
542
|
+
disposeListener();
|
|
543
|
+
// Expired tracking must not swallow a later orphan reply. A live
|
|
544
|
+
// body claim instead stays present until its send/relay settles.
|
|
545
|
+
if (!ownsBody)
|
|
546
|
+
pendingQueueRequestIds.delete(requestId);
|
|
547
|
+
terminal = run()
|
|
548
|
+
.catch(error => log.info('[slack:queue]', logErrorText(error)))
|
|
549
|
+
.finally(() => {
|
|
550
|
+
signal.removeEventListener('abort', abortInput);
|
|
551
|
+
pendingQueueRequestIds.delete(requestId);
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
return terminal;
|
|
555
|
+
}
|
|
556
|
+
function endTracking(outcome, reason) {
|
|
557
|
+
return claimTerminal(async () => {
|
|
558
|
+
await Promise.allSettled([
|
|
559
|
+
display.finish(outcome, reason ? { reason } : {}), settleAck('failure'),
|
|
560
|
+
]);
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
function resetTimer() {
|
|
564
|
+
if (disposed)
|
|
565
|
+
return;
|
|
566
|
+
if (timer)
|
|
567
|
+
clearTimeout(timer);
|
|
568
|
+
timer = setTimeout(() => { void endTracking('expired'); }, started ? SLACK_RUN_IDLE_MS : SLACK_QUEUE_WAIT_MS);
|
|
569
|
+
timer.unref?.();
|
|
570
|
+
}
|
|
571
|
+
function abortInput() {
|
|
572
|
+
shutdownSealed = true;
|
|
573
|
+
display.seal();
|
|
574
|
+
void display.finish(executionOutcome ?? 'expired');
|
|
575
|
+
void endTracking(executionOutcome ?? 'expired');
|
|
576
|
+
}
|
|
577
|
+
function queueHandler(type, data) {
|
|
578
|
+
if (disposed || !matches(data))
|
|
579
|
+
return;
|
|
580
|
+
if (type === 'queued_run_started')
|
|
581
|
+
return; // Global receipt owner captures the anchor synchronously.
|
|
582
|
+
if (type === 'request_settled') {
|
|
583
|
+
if (started)
|
|
584
|
+
return; // The display owner latches outcomes; preserve salvage delivery.
|
|
585
|
+
if (data['outcome'] === 'failed')
|
|
586
|
+
void endTracking('error');
|
|
587
|
+
if (data['outcome'] === 'cancelled')
|
|
588
|
+
void endTracking('cancelled');
|
|
589
|
+
if (data['outcome'] === 'dropped')
|
|
590
|
+
void endTracking('cancelled', 'removed');
|
|
591
|
+
if (data['outcome'] === 'merged')
|
|
592
|
+
void endTracking('cancelled', 'merged');
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (type !== 'orchestrate_done' || data['origin'] !== 'slack')
|
|
596
|
+
return;
|
|
597
|
+
void claimTerminal(() => {
|
|
598
|
+
let bodyActionStarted = false;
|
|
599
|
+
return slackReplyDelivery.deliver(requestId, target, anchor => {
|
|
600
|
+
bodyActionStarted = true;
|
|
601
|
+
display.phase('delivering');
|
|
602
|
+
return sessionLanes.runDetachedTurn(session.scope, async () => {
|
|
603
|
+
let delivered = false;
|
|
604
|
+
let confirmedDelivery = false;
|
|
605
|
+
try {
|
|
606
|
+
if (!current()) {
|
|
607
|
+
await Promise.allSettled([display.finish(executionOutcome ?? 'expired'), settleAck('failure')]);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
const requireBodyDelivery = requiresNativeBodyDelivery(data);
|
|
611
|
+
const rawText = String(data['text'] ?? '');
|
|
612
|
+
const text = data['error'] === true && !requireBodyDelivery ? t('slack.progress.failure', {}, locale)
|
|
613
|
+
: requireBodyDelivery && !rawText.trim() ? '' : rawText;
|
|
614
|
+
display.phase('delivering');
|
|
615
|
+
const outbound = slackOutboundRegistry.start(signal);
|
|
616
|
+
try {
|
|
617
|
+
const alreadyDelivered = text && (anchor ?? observedAnchor) !== undefined && wasSelfDelivered({ target, text, since: (anchor ?? observedAnchor) });
|
|
618
|
+
const sent = text ? (alreadyDelivered ? { ok: true } : await sendSlackText(token, target, text, {
|
|
619
|
+
signal: outbound.signal, ...(requireBodyDelivery ? { requireBodyDelivery: true } : {}),
|
|
620
|
+
onPosted: async () => {
|
|
621
|
+
// Same early-settle rule as the direct path: every chunk is
|
|
622
|
+
// posted, so the ACK must not wait on readback verification.
|
|
623
|
+
delivered = true;
|
|
624
|
+
const early = bodyProgressOutcome(data, true, executionOutcome, !current());
|
|
625
|
+
await settleAck(early === 'complete' ? 'success' : 'failure');
|
|
626
|
+
},
|
|
627
|
+
})) : { ok: false };
|
|
628
|
+
delivered = sent.ok;
|
|
629
|
+
confirmedDelivery = Boolean(alreadyDelivered) || (sent.ok && Boolean(sent.ts));
|
|
630
|
+
}
|
|
631
|
+
finally {
|
|
632
|
+
outbound.done();
|
|
633
|
+
}
|
|
634
|
+
const outcome = bodyProgressOutcome(data, delivered, executionOutcome, !current());
|
|
635
|
+
if (delivered && target.threadId)
|
|
636
|
+
markThreadParticipated(target.targetId, target.threadId);
|
|
637
|
+
await settleAck(delivered && outcome === 'complete' ? 'success' : 'failure');
|
|
638
|
+
await display.finish(outcome, { bodyDelivered: confirmedDelivery });
|
|
639
|
+
if (text && current()) {
|
|
640
|
+
const relay = slackOutboundRegistry.start(signal);
|
|
641
|
+
try {
|
|
642
|
+
await relaySlackImages(token, target, text, { signal: relay.signal });
|
|
643
|
+
}
|
|
644
|
+
catch (error) {
|
|
645
|
+
log.error('[slack:queue-relay]', logErrorText(error));
|
|
646
|
+
}
|
|
647
|
+
finally {
|
|
648
|
+
relay.done();
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
log.error('[slack:queue-send]', logErrorText(error));
|
|
654
|
+
await Promise.allSettled([
|
|
655
|
+
settleAck('failure'),
|
|
656
|
+
display.finish(bodyProgressOutcome(data, delivered, executionOutcome, !current()), { bodyDelivered: confirmedDelivery }),
|
|
657
|
+
]);
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
}).finally(async () => {
|
|
661
|
+
if (!bodyActionStarted) {
|
|
662
|
+
await Promise.allSettled([display.finish(executionOutcome ?? 'expired'), settleAck('failure')]);
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
}, true);
|
|
666
|
+
}
|
|
667
|
+
reserveSlackNoticeRecord(requestId, target);
|
|
668
|
+
display = createSlackProgressLifecycle({
|
|
669
|
+
token, target, requestId, scope: session.scope, sessionId: session.chatSessionId, locale,
|
|
670
|
+
...(options.workingDir ? { workingDir: options.workingDir } : {}),
|
|
671
|
+
...(recipientUserId ? { recipientUserId } : {}),
|
|
672
|
+
registerTeardown: registerSlackProgressTeardown,
|
|
673
|
+
onPosted: ts => attachSlackNoticeRecord(requestId, ts),
|
|
674
|
+
onTerminalConfirmed: () => closeSlackNoticeRecord(requestId),
|
|
675
|
+
onExecutionOutcome: outcome => { executionOutcome = outcome; },
|
|
676
|
+
onActivity: () => { if (started)
|
|
677
|
+
resetTimer(); },
|
|
678
|
+
onSeal: () => { shutdownSealed = true; void settleAck('failure'); void endTracking(executionOutcome ?? 'expired'); },
|
|
679
|
+
});
|
|
680
|
+
function observeStart(anchor) {
|
|
681
|
+
if (disposed || !current())
|
|
682
|
+
return;
|
|
683
|
+
observedAnchor = slackReplyDelivery.started(requestId, target, session.scope, anchor);
|
|
684
|
+
if (!started) {
|
|
685
|
+
started = true;
|
|
686
|
+
display.phase('running');
|
|
687
|
+
resetTimer();
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
activeSlackReplyTrackers.set(requestId, { options, start: observeStart });
|
|
691
|
+
addBroadcastListener(queueHandler);
|
|
692
|
+
pendingQueueRequestIds.add(requestId);
|
|
693
|
+
signal.addEventListener('abort', abortInput, { once: true });
|
|
694
|
+
resetTimer();
|
|
695
|
+
display.start({ initialPhase });
|
|
696
|
+
void ack?.to('running', { wasQueued: initialPhase === 'queued' });
|
|
697
|
+
}
|
|
372
698
|
function installSlackTargetReplyForwarder() {
|
|
373
699
|
if (targetReplyForwarderInstalled)
|
|
374
700
|
return;
|
|
375
701
|
targetReplyForwarderInstalled = true;
|
|
376
702
|
addBroadcastListener((type, data) => {
|
|
377
|
-
|
|
703
|
+
observeSlackReplyControl(type, data);
|
|
704
|
+
if (slackStopping || type !== 'orchestrate_done' || data["origin"] !== 'slack')
|
|
378
705
|
return;
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
// here too would post every reply twice.
|
|
706
|
+
// Only captured queued/restart delivery authority can use this fallback.
|
|
707
|
+
// Ordinary direct turns retain their awaiting dispatch owner.
|
|
382
708
|
//
|
|
383
709
|
// Errors included: after a restart there is no waiter to show them, so
|
|
384
710
|
// dropping them here means the user's message vanishes without even a
|
|
385
711
|
// failure notice.
|
|
386
|
-
//
|
|
387
712
|
// A steered turn is the same shape of orphan as a queued one: ingress
|
|
388
|
-
// returns early unless the disposition is
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
if (data["fromQueue"] !== true && data["fromSteer"] !== true)
|
|
713
|
+
// returns early unless the disposition is new_run, so a mid-run steer leaves
|
|
714
|
+
// NO waiter for the follow-up answer (#655). Accept all three identities.
|
|
715
|
+
if (data["fromQueue"] !== true && data["fromSteer"] !== true && data["replyViaTarget"] !== true)
|
|
392
716
|
return;
|
|
393
|
-
const
|
|
394
|
-
if (!
|
|
717
|
+
const rawTarget = data['target'];
|
|
718
|
+
if (!isRemoteTarget(rawTarget) || rawTarget.channel !== 'slack')
|
|
395
719
|
return;
|
|
720
|
+
const target = { ...rawTarget };
|
|
396
721
|
// A live requester is already listening for this exact result; posting
|
|
397
722
|
// here too would double-post it.
|
|
398
723
|
if (data["requestId"] && hasPendingQueueWaiter(String(data["requestId"])))
|
|
@@ -400,15 +725,25 @@ function installSlackTargetReplyForwarder() {
|
|
|
400
725
|
const token = getSlackSendClient().token;
|
|
401
726
|
if (!token)
|
|
402
727
|
return;
|
|
403
|
-
const
|
|
728
|
+
const generation = lifecycleGeneration;
|
|
404
729
|
const requireBodyDelivery = requiresNativeBodyDelivery(data);
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
730
|
+
const text = data['error'] === true && !requireBodyDelivery
|
|
731
|
+
? t('slack.progress.failure', {}, currentLocale()) : String(data['text'] ?? '');
|
|
732
|
+
const requestId = typeof data['requestId'] === 'string' ? data['requestId'] : '';
|
|
733
|
+
const scope = slackReplyDelivery.scope(requestId, target)
|
|
734
|
+
?? (typeof data['scope'] === 'string' ? data['scope'] : 'default');
|
|
735
|
+
void slackReplyDelivery.deliver(requestId, target, anchor => sessionLanes.runDetachedTurn(scope, async () => {
|
|
736
|
+
if (generation !== lifecycleGeneration || slackStopping)
|
|
737
|
+
return;
|
|
738
|
+
if (!text || (requireBodyDelivery && !text.trim()))
|
|
739
|
+
return;
|
|
408
740
|
const outbound = slackOutboundRegistry.start();
|
|
409
741
|
try {
|
|
410
|
-
const
|
|
742
|
+
const alreadyDelivered = anchor !== undefined && wasSelfDelivered({ target, text, since: anchor });
|
|
743
|
+
const result = alreadyDelivered ? { ok: true } : await sendSlackText(token, target, text, { signal: outbound.signal,
|
|
411
744
|
...(requireBodyDelivery ? { requireBodyDelivery: true } : {}) });
|
|
745
|
+
if (outbound.signal.aborted || generation !== lifecycleGeneration || slackStopping)
|
|
746
|
+
return;
|
|
412
747
|
if (!result.ok) {
|
|
413
748
|
log.error('[slack:target-reply]', logErrorText(result.error || 'send failed'));
|
|
414
749
|
return;
|
|
@@ -419,7 +754,7 @@ function installSlackTargetReplyForwarder() {
|
|
|
419
754
|
// is now noise. The live waiter usually owns this; when the
|
|
420
755
|
// forwarder delivers instead, it must also keep the promise.
|
|
421
756
|
if (data["requestId"]) {
|
|
422
|
-
await closeSlackNoticeAsAnsweredByRequestId(String(data["requestId"]));
|
|
757
|
+
await closeSlackNoticeAsAnsweredByRequestId(String(data["requestId"]), token, outbound.signal, generation);
|
|
423
758
|
}
|
|
424
759
|
await relaySlackImages(token, target, text, { signal: outbound.signal });
|
|
425
760
|
}
|
|
@@ -429,7 +764,7 @@ function installSlackTargetReplyForwarder() {
|
|
|
429
764
|
finally {
|
|
430
765
|
outbound.done();
|
|
431
766
|
}
|
|
432
|
-
})();
|
|
767
|
+
})).catch(error => log.error('[slack:target-reply]', logErrorText(error)));
|
|
433
768
|
});
|
|
434
769
|
}
|
|
435
770
|
installSlackTargetReplyForwarder();
|
|
@@ -439,8 +774,10 @@ async function slackOrchestrate(target, prompt, displayMsg, signal, dedupe = {})
|
|
|
439
774
|
if (!client.token)
|
|
440
775
|
return;
|
|
441
776
|
const token = client.token;
|
|
777
|
+
const workingDir = typeof settings['workingDir'] === 'string' ? settings['workingDir'] : undefined;
|
|
778
|
+
target = { ...target };
|
|
442
779
|
const chatId = target.targetId;
|
|
443
|
-
if (signal.aborted)
|
|
780
|
+
if (signal.aborted || slackStopping)
|
|
444
781
|
return;
|
|
445
782
|
// #321: the reservation was taken before an `await` and several early
|
|
446
783
|
// returns. If a reset landed in that window this delivery belongs to a dead
|
|
@@ -460,317 +797,156 @@ async function slackOrchestrate(target, prompt, displayMsg, signal, dedupe = {})
|
|
|
460
797
|
isMention: dedupe.isMention ?? false,
|
|
461
798
|
})
|
|
462
799
|
: null;
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
//
|
|
481
|
-
// An event with no requestId cannot prove which run it belongs to.
|
|
482
|
-
// Letting it through would restore the old behaviour, so it is
|
|
483
|
-
// dropped; emitAgentTool stamps the field from its run context.
|
|
484
|
-
if (data['requestId'] !== ctx.requestId)
|
|
485
|
-
return;
|
|
486
|
-
// Thinking/commentary entries are live-UI-only narration.
|
|
487
|
-
// Telegram already drops its 💭 lines; Slack's progress
|
|
488
|
-
// placeholder gets real tool activity only, so agent
|
|
489
|
-
// reasoning text never lands in the channel even briefly.
|
|
490
|
-
// 💬 is the untagged-codex assistant-message badge (spark
|
|
491
|
-
// visibility) — also narration from the channel's view.
|
|
492
|
-
if (data['toolType'] === 'thinking' || data['icon'] === '💬')
|
|
493
|
-
return;
|
|
494
|
-
const line = statusFromToolEvent(data, t('slack.progress.working', {}, currentLocale()));
|
|
495
|
-
if (line)
|
|
496
|
-
progress.update(line);
|
|
800
|
+
let result;
|
|
801
|
+
slackReplyAdmissionDepth++;
|
|
802
|
+
try {
|
|
803
|
+
result = admitSlackRun({
|
|
804
|
+
target, prompt, displayText: displayMsg, chatId,
|
|
805
|
+
...(dedupe.toolSource ? { toolSource: dedupe.toolSource } : {}),
|
|
806
|
+
...(dedupe.preResolvedScope !== undefined
|
|
807
|
+
? { preResolvedScope: dedupe.preResolvedScope } : {}),
|
|
808
|
+
runReply: async (ctx) => {
|
|
809
|
+
const locale = currentLocale();
|
|
810
|
+
const generation = lifecycleGeneration;
|
|
811
|
+
let shutdownSealed = false;
|
|
812
|
+
let executionOutcome;
|
|
813
|
+
let ackPromise = null;
|
|
814
|
+
const settleAck = (outcome) => {
|
|
815
|
+
ackPromise ??= ack?.settle(outcome) ?? Promise.resolve();
|
|
816
|
+
return ackPromise;
|
|
497
817
|
};
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
818
|
+
let bodyAttempted = false;
|
|
819
|
+
let bodySucceeded = false;
|
|
820
|
+
let bodyConfirmed = false;
|
|
821
|
+
let resultData = {};
|
|
822
|
+
const current = () => !shutdownSealed && !slackStopping && !signal.aborted && generation === lifecycleGeneration;
|
|
823
|
+
if (!current())
|
|
824
|
+
return;
|
|
825
|
+
if (ctx.requestId)
|
|
826
|
+
reserveSlackNoticeRecord(ctx.requestId, target);
|
|
827
|
+
const display = createSlackProgressLifecycle({
|
|
828
|
+
token, target, requestId: ctx.requestId, scope: ctx.scope, sessionId: ctx.chatSessionId, locale,
|
|
829
|
+
...(workingDir ? { workingDir } : {}),
|
|
830
|
+
...(dedupe.recipientUserId ? { recipientUserId: dedupe.recipientUserId } : {}),
|
|
831
|
+
registerTeardown: registerSlackProgressTeardown,
|
|
832
|
+
onPosted: ts => attachSlackNoticeRecord(ctx.requestId, ts),
|
|
833
|
+
onTerminalConfirmed: () => closeSlackNoticeRecord(ctx.requestId),
|
|
834
|
+
onExecutionOutcome: outcome => { executionOutcome = outcome; },
|
|
835
|
+
onSeal: () => { shutdownSealed = true; void settleAck('failure'); },
|
|
836
|
+
});
|
|
837
|
+
const abortInput = () => {
|
|
838
|
+
display.seal();
|
|
839
|
+
void display.finish(executionOutcome ?? 'expired');
|
|
840
|
+
};
|
|
841
|
+
signal.addEventListener('abort', abortInput, { once: true });
|
|
842
|
+
display.start({ initialPhase: 'running' });
|
|
843
|
+
void ack?.to('running');
|
|
503
844
|
const turnStartedAt = nextDeliverySeq();
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
845
|
+
try {
|
|
846
|
+
const collected = await withSessionScope({ scope: ctx.scope, chatSessionId: ctx.chatSessionId }, () => orchestrateAndCollectData(prompt, {
|
|
847
|
+
origin: 'slack', target, chatId, requestId: ctx.requestId,
|
|
848
|
+
...(dedupe.toolSource ? { _strictRequestOwnership: true } : {}),
|
|
849
|
+
...(ctx.remoteKey ? { remoteKey: ctx.remoteKey } : {}),
|
|
850
|
+
chatSessionId: ctx.chatSessionId, scope: ctx.scope, _skipInsert: true,
|
|
851
|
+
}));
|
|
852
|
+
resultData = collected.data;
|
|
853
|
+
if (!current())
|
|
510
854
|
return;
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
855
|
+
// A turn retired by a steer has no answer of its own: the follow-up
|
|
856
|
+
// run owns it and the reply tracker delivers it. Posting the no-response
|
|
857
|
+
// placeholder here is what the user saw right after steering (#655).
|
|
858
|
+
if (resultData['superseded'] === true && !String(collected.text ?? '').trim()) {
|
|
859
|
+
log.info(`[slack:out:superseded] ${target.targetId}: retired by steer`);
|
|
860
|
+
await settleAck('success');
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
const text = collected.data.collectionFailure === 'error' ? t('slack.progress.failure', {}, locale)
|
|
864
|
+
: collected.data.collectionFailure === 'timeout' ? t('tg.timeout', {}, locale) : collected.text;
|
|
865
|
+
display.phase('delivering');
|
|
866
|
+
const outbound = slackOutboundRegistry.start(signal);
|
|
867
|
+
try {
|
|
868
|
+
bodyAttempted = true;
|
|
869
|
+
const alreadyDelivered = wasSelfDelivered({ target, text, since: turnStartedAt });
|
|
870
|
+
const sendResult = alreadyDelivered ? { ok: true } : await sendSlackText(token, target, text, {
|
|
871
|
+
signal: outbound.signal,
|
|
872
|
+
...(requiresNativeBodyDelivery(collected.data) ? { requireBodyDelivery: true } : {}),
|
|
873
|
+
onPosted: async () => {
|
|
874
|
+
// Every chunk is posted, so the answer is visible. Settle
|
|
875
|
+
// the ACK here: readback verification below can take
|
|
876
|
+
// seconds per chunk and must not hold the reaction (#417).
|
|
877
|
+
bodySucceeded = true;
|
|
878
|
+
const early = bodyProgressOutcome(resultData, true, executionOutcome, !current());
|
|
879
|
+
await settleAck(early === 'complete' ? 'success' : 'failure');
|
|
880
|
+
},
|
|
881
|
+
});
|
|
882
|
+
bodySucceeded = sendResult.ok;
|
|
883
|
+
bodyConfirmed = alreadyDelivered || (sendResult.ok && Boolean(sendResult.ts));
|
|
884
|
+
const outcome = bodyProgressOutcome(resultData, bodySucceeded, executionOutcome, !current());
|
|
885
|
+
if (bodySucceeded && target.threadId)
|
|
886
|
+
markThreadParticipated(target.targetId, target.threadId);
|
|
887
|
+
await settleAck(bodySucceeded && outcome === 'complete' ? 'success' : 'failure');
|
|
888
|
+
await display.finish(outcome, { bodyDelivered: bodyConfirmed });
|
|
889
|
+
if (current())
|
|
890
|
+
await relaySlackImages(token, target, text, { signal: outbound.signal });
|
|
891
|
+
log.info(`[slack:out${alreadyDelivered ? ':skipped-self-delivered' : ''}] ${target.targetId}: ${redactOutboundText(text).slice(0, 80)}`);
|
|
892
|
+
}
|
|
893
|
+
finally {
|
|
894
|
+
outbound.done();
|
|
895
|
+
}
|
|
523
896
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
// relay can still throw after the text is out, and the user did
|
|
544
|
-
// get their answer in that case — so the outcome is success and
|
|
545
|
-
// settling twice would be a lie about which one happened.
|
|
546
|
-
ackOutcome = sendResult.ok ? 'success' : 'failure';
|
|
547
|
-
// A successful reply records presence, not ownership (marking
|
|
548
|
-
// point b). Replying into a thread we were invited to does not
|
|
549
|
-
// make the rest of that conversation ours; the default `joined`
|
|
550
|
-
// says so, and an already-`owned` thread keeps its kind (#400).
|
|
551
|
-
if (sendResult.ok && target.threadId) {
|
|
552
|
-
markThreadParticipated(target.targetId, target.threadId);
|
|
897
|
+
catch (err) {
|
|
898
|
+
log.error('[slack:error]', logErrorText(err));
|
|
899
|
+
if (current())
|
|
900
|
+
executionOutcome ??= 'error';
|
|
901
|
+
// Never append a second diagnostic after an ambiguous body attempt.
|
|
902
|
+
if (!bodyAttempted && current()) {
|
|
903
|
+
bodyAttempted = true;
|
|
904
|
+
const outbound = slackOutboundRegistry.start(signal);
|
|
905
|
+
try {
|
|
906
|
+
const diagnostic = await sendSlackText(token, target, t('slack.progress.failure', {}, locale), { signal: outbound.signal });
|
|
907
|
+
bodySucceeded = diagnostic.ok;
|
|
908
|
+
bodyConfirmed = diagnostic.ok && Boolean(diagnostic.ts);
|
|
909
|
+
}
|
|
910
|
+
catch (error) {
|
|
911
|
+
log.error('[slack:diagnostic]', logErrorText(error));
|
|
912
|
+
}
|
|
913
|
+
finally {
|
|
914
|
+
outbound.done();
|
|
915
|
+
}
|
|
553
916
|
}
|
|
554
|
-
// Settled BEFORE the relay: the upload is now abortable but
|
|
555
|
-
// still slow, and the reaction must not sit on `running`
|
|
556
|
-
// while the answer is already visible (#417).
|
|
557
|
-
await ack?.settle(ackOutcome);
|
|
558
|
-
ackSettled = true;
|
|
559
|
-
// Relayed even when the text above was suppressed. Whether
|
|
560
|
-
// the agent already uploaded these bytes cannot be proven
|
|
561
|
-
// from a path (see turn-delivery.ts), and a skip that cannot
|
|
562
|
-
// be proven is a silent drop.
|
|
563
|
-
await relaySlackImages(token, target, text, { signal: outbound.signal });
|
|
564
|
-
// Logged either way, and labelled, because the previous
|
|
565
|
-
// silence here is what made the duplicate hard to see: only
|
|
566
|
-
// the dispatch post wrote [slack:out], so the log showed one
|
|
567
|
-
// delivery while the user had received two.
|
|
568
|
-
log.info(`[slack:out${alreadyDelivered ? ':skipped-self-delivered' : ''}] ${target.targetId}: ${redactOutboundText(text).slice(0, 80)}`);
|
|
569
917
|
}
|
|
570
918
|
finally {
|
|
571
|
-
|
|
919
|
+
signal.removeEventListener('abort', abortInput);
|
|
920
|
+
await settleAck('failure');
|
|
921
|
+
await display.finish(bodyProgressOutcome(resultData, bodySucceeded, executionOutcome, !current()), bodyAttempted ? { bodyDelivered: bodyConfirmed } : {});
|
|
572
922
|
}
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
// Exactly one settle per turn, whichever way the body exited.
|
|
580
|
-
// The happy path already settled before the image relay.
|
|
581
|
-
if (!ackSettled)
|
|
582
|
-
await ack?.settle(ackOutcome);
|
|
583
|
-
}
|
|
584
|
-
},
|
|
585
|
-
});
|
|
923
|
+
},
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
finally {
|
|
927
|
+
slackReplyAdmissionDepth--;
|
|
928
|
+
}
|
|
586
929
|
// Durable commit AFTER admission, with no await in between: an event that
|
|
587
930
|
// died before this line stays redeliverable, which is the whole point of
|
|
588
931
|
// ordering it here rather than at reservation time.
|
|
589
932
|
if (dedupe.eventKey && result.action !== 'rejected')
|
|
590
933
|
commitSlackEvent(dedupe.eventKey);
|
|
591
934
|
result.laneTail?.catch(error => log.error('[slack:lane]', logErrorText(error)));
|
|
592
|
-
if (result.action === 'queued') {
|
|
593
|
-
log.info(`[slack:queue] agent busy, queued (${result.pending} pending)`);
|
|
935
|
+
if (result.action === 'queued' || (result.action === 'started' && result.disposition === 'steered')) {
|
|
594
936
|
const requestId = result.requestId;
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
// queued answer. Until it latches it reads as Infinity, so nothing is
|
|
599
|
-
// suppressed. A restart loses claims entirely, which keeps the #407
|
|
600
|
-
// orphan delivery intact.
|
|
601
|
-
const queuedAnchor = pendingDeliveryAnchor();
|
|
602
|
-
const queuedRunStarted = (type, data) => {
|
|
603
|
-
// Keyed on THIS request. A scope-blind "an agent started" signal can
|
|
604
|
-
// fire for the turn ahead of this one, latching the anchor while that
|
|
605
|
-
// turn is still running — and a claim it records afterwards would then
|
|
606
|
-
// sort as newer than the anchor and swallow this answer.
|
|
607
|
-
if (type === 'queued_run_started' && data['requestId'] === requestId)
|
|
608
|
-
queuedAnchor.latch();
|
|
609
|
-
};
|
|
610
|
-
addBroadcastListener(queuedRunStarted);
|
|
611
|
-
let queueTimeout;
|
|
612
|
-
let disposed = false;
|
|
613
|
-
const notice = createQueueNotice({
|
|
614
|
-
expiredText: t('tg.queueExpired', {}, currentLocale()),
|
|
615
|
-
onError: (e) => log.info('[slack:queue-notice]', logErrorText(e)),
|
|
616
|
-
});
|
|
617
|
-
const disposeListener = () => {
|
|
618
|
-
if (disposed)
|
|
619
|
-
return;
|
|
620
|
-
disposed = true;
|
|
621
|
-
clearTimeout(queueTimeout);
|
|
622
|
-
removeBroadcastListener(queueHandler);
|
|
623
|
-
removeBroadcastListener(queuedRunStarted);
|
|
624
|
-
if (requestId)
|
|
625
|
-
pendingQueueRequestIds.delete(requestId);
|
|
626
|
-
};
|
|
627
|
-
// One terminal outcome per turn, shared by whoever gets there first.
|
|
628
|
-
//
|
|
629
|
-
// A boolean flag would pick a winner and let the loser return
|
|
630
|
-
// immediately — and the loser here is the shutdown drain, which would
|
|
631
|
-
// then tear the transport down while the winner is still sending. The
|
|
632
|
-
// broadcast bus never awaits listener promises (core/bus.ts), so nothing
|
|
633
|
-
// else would hold it. Handing back the winner's promise makes both paths
|
|
634
|
-
// converge on the same completion.
|
|
635
|
-
let terminal = null;
|
|
636
|
-
// Assigned immediately below, but referenced by finishExpired, which is
|
|
637
|
-
// defined first. Declared here so the terminal claim can unregister.
|
|
638
|
-
let unregister = () => { };
|
|
639
|
-
const claimTerminal = (run) => {
|
|
640
|
-
if (!terminal) {
|
|
641
|
-
terminal = run().catch(e => log.info('[slack:queue]', logErrorText(e)));
|
|
642
|
-
}
|
|
643
|
-
return terminal;
|
|
644
|
-
};
|
|
645
|
-
const finishExpired = (signal) => claimTerminal(async () => {
|
|
646
|
-
disposeListener();
|
|
647
|
-
try {
|
|
648
|
-
// Started together, not in sequence: awaiting the notice first can
|
|
649
|
-
// eat the whole drain deadline before the reaction is even attempted.
|
|
650
|
-
await Promise.allSettled([
|
|
651
|
-
notice.close('expired', signal),
|
|
652
|
-
ack?.settle('failure') ?? Promise.resolve(),
|
|
653
|
-
]);
|
|
654
|
-
// The in-process handle just closed this notice, so the durable
|
|
655
|
-
// record has nothing left to restore. Dropping it here is what
|
|
656
|
-
// keeps the next boot from rewriting a message this turn already
|
|
657
|
-
// dealt with.
|
|
658
|
-
if (requestId)
|
|
659
|
-
closeSlackNoticeRecord(requestId);
|
|
660
|
-
}
|
|
661
|
-
finally {
|
|
662
|
-
// Centralized here, as in the Discord path: every terminal route
|
|
663
|
-
// passes through a claim, so unregistering in one place is what
|
|
664
|
-
// makes "exactly once" true. The plain timeout used to leave its
|
|
665
|
-
// registry entry behind, so a later shutdown re-ran a turn that
|
|
666
|
-
// had already finished.
|
|
667
|
-
unregister();
|
|
668
|
-
}
|
|
669
|
-
});
|
|
670
|
-
unregister = slackNoticeRegistry.add((signal) => finishExpired(signal));
|
|
671
|
-
const queueHandler = async (type, data) => {
|
|
672
|
-
// No !data.text gate: an empty completion must still claim the
|
|
673
|
-
// terminal, or the notice sits until the 5-minute timeout rewrites
|
|
674
|
-
// it to "expired" — the reported "안 없어지는" symptom for empty or
|
|
675
|
-
// error turns. Empty text closes the notice as expired immediately.
|
|
676
|
-
if (type !== 'orchestrate_done' || data["origin"] !== 'slack'
|
|
677
|
-
|| data["requestId"] !== requestId)
|
|
678
|
-
return;
|
|
679
|
-
if (disposed)
|
|
680
|
-
return;
|
|
681
|
-
// Dispose FIRST so a duplicate broadcast cannot double-post, but the
|
|
682
|
-
// notice deliberately outlives it: deleting before the answer is out
|
|
683
|
-
// would leave a failed send with neither answer nor notice.
|
|
684
|
-
disposeListener();
|
|
685
|
-
await claimTerminal(async () => {
|
|
686
|
-
try {
|
|
687
|
-
const requireBodyDelivery = requiresNativeBodyDelivery(data);
|
|
688
|
-
const rawText = String(data["text"] ?? '');
|
|
689
|
-
const text = requireBodyDelivery && !rawText.trim() ? '' : rawText;
|
|
690
|
-
const outbound = slackOutboundRegistry.start();
|
|
691
|
-
let queuedSendResult;
|
|
692
|
-
try {
|
|
693
|
-
// Same rule as the normal dispatch: if the queued agent
|
|
694
|
-
// already posted this answer itself, posting it again is
|
|
695
|
-
// the duplicate. The notice still closes as answered
|
|
696
|
-
// because the user does have the answer.
|
|
697
|
-
const alreadyDelivered = text
|
|
698
|
-
&& wasSelfDelivered({ target, text, since: queuedAnchor.value() });
|
|
699
|
-
queuedSendResult = text
|
|
700
|
-
? (alreadyDelivered
|
|
701
|
-
? { ok: true }
|
|
702
|
-
: await sendSlackText(token, target, text, { signal: outbound.signal,
|
|
703
|
-
...(requireBodyDelivery ? { requireBodyDelivery: true } : {}) }))
|
|
704
|
-
: { ok: false };
|
|
705
|
-
}
|
|
706
|
-
finally {
|
|
707
|
-
// Released after the body; the relay below opens its own
|
|
708
|
-
// scope so a slow upload does not hold this one.
|
|
709
|
-
outbound.done();
|
|
710
|
-
}
|
|
711
|
-
await notice.close(queuedSendResult.ok ? 'answered' : 'expired');
|
|
712
|
-
if (queuedSendResult.ok && target.threadId) {
|
|
713
|
-
markThreadParticipated(target.targetId, target.threadId);
|
|
714
|
-
}
|
|
715
|
-
// Settled before the relay for the same reason as the normal
|
|
716
|
-
// path: the text is what the user was waiting for, and an
|
|
717
|
-
// uncancellable upload must not hold the reaction on running.
|
|
718
|
-
await ack?.settle(queuedSendResult.ok ? 'success' : 'failure');
|
|
719
|
-
if (text) {
|
|
720
|
-
const relayScope = slackOutboundRegistry.start();
|
|
721
|
-
await relaySlackImages(token, target, text, {
|
|
722
|
-
signal: relayScope.signal
|
|
723
|
-
})
|
|
724
|
-
.catch(e => log.error('[slack:queue-send]', logErrorText(e)))
|
|
725
|
-
.finally(() => relayScope.done());
|
|
726
|
-
}
|
|
727
|
-
// The notice is closed, so the durable record has nothing
|
|
728
|
-
// left to restore on the next boot (#418).
|
|
729
|
-
if (requestId)
|
|
730
|
-
closeSlackNoticeRecord(requestId);
|
|
731
|
-
}
|
|
732
|
-
finally {
|
|
733
|
-
// Same "exactly once" reasoning as finishExpired: whichever
|
|
734
|
-
// path claims the terminal owns the registry cleanup.
|
|
735
|
-
unregister();
|
|
736
|
-
}
|
|
737
|
-
});
|
|
738
|
-
};
|
|
739
|
-
// Everything below is armed SYNCHRONOUSLY, before any await. A reaction
|
|
740
|
-
// call that takes a moment must not be able to outlive the completion it
|
|
741
|
-
// is acknowledging: without the listener and the request-id claim in
|
|
742
|
-
// place, a fast queued job would either be missed here or answered by the
|
|
743
|
-
// standing fallback forwarder instead (#407).
|
|
744
|
-
addBroadcastListener(queueHandler);
|
|
745
|
-
if (requestId)
|
|
746
|
-
pendingQueueRequestIds.add(requestId);
|
|
747
|
-
queueTimeout = setTimeout(() => { void finishExpired(); }, 300000);
|
|
748
|
-
// Only now, with the lifecycle armed. Not awaited: the notice below is
|
|
749
|
-
// what the user needs to see, and the reaction is decoration on top.
|
|
750
|
-
void ack?.to('running', { wasQueued: true });
|
|
751
|
-
// Reserved BEFORE the post, so the crash window falls the harmless way: a
|
|
752
|
-
// record without an id restores to nothing, while a posted message without
|
|
753
|
-
// a record is unreachable forever (#418).
|
|
754
|
-
if (requestId)
|
|
755
|
-
reserveSlackNoticeRecord(requestId, target);
|
|
756
|
-
const posted = await sendSlackText(token, target, t('tg.queued', { count: result.pending }, currentLocale()));
|
|
757
|
-
if (posted.ok && posted.ts) {
|
|
758
|
-
const ts = posted.ts;
|
|
759
|
-
// The exported factory, not an inline object: the restart path builds
|
|
760
|
-
// the same transport, and a second copy is how the two drift.
|
|
761
|
-
notice.bind(createSlackNoticeTransport(token, target.targetId, ts));
|
|
762
|
-
if (requestId)
|
|
763
|
-
attachSlackNoticeRecord(requestId, ts);
|
|
764
|
-
}
|
|
765
|
-
else {
|
|
766
|
-
// No ts will ever arrive, so a deferred close would wait for a bind
|
|
767
|
-
// that cannot happen and the drain would burn its whole deadline.
|
|
768
|
-
notice.abandon();
|
|
769
|
-
// Nothing was posted, so the reservation describes a message that does
|
|
770
|
-
// not exist. Leaving it would make every later boot look for it.
|
|
771
|
-
if (requestId)
|
|
772
|
-
closeSlackNoticeRecord(requestId);
|
|
937
|
+
if (!requestId) {
|
|
938
|
+
await ack?.settle('failure');
|
|
939
|
+
return;
|
|
773
940
|
}
|
|
941
|
+
const options = {
|
|
942
|
+
token, target, requestId, session: { ...result.sessionContext }, locale: currentLocale(),
|
|
943
|
+
generation: lifecycleGeneration, signal, ack, ...(workingDir ? { workingDir } : {}), initialPhase: 'queued',
|
|
944
|
+
...(dedupe.recipientUserId ? { recipientUserId: dedupe.recipientUserId } : {}),
|
|
945
|
+
};
|
|
946
|
+
if (result.action === 'queued')
|
|
947
|
+
trackSlackReply(options);
|
|
948
|
+
else
|
|
949
|
+
rememberPendingSteer(options);
|
|
774
950
|
return;
|
|
775
951
|
}
|
|
776
952
|
if (result.action === 'rejected') {
|
|
@@ -862,7 +1038,15 @@ async function runSlackMessageEvent(event, target, text, signal, opts, commitPre
|
|
|
862
1038
|
// the message, and the conversation is already obvious in Slack's own UI.
|
|
863
1039
|
displayText = buildSenderDisplay(identity, displayText);
|
|
864
1040
|
}
|
|
1041
|
+
const sourceToken = getSlackSendClient().token;
|
|
1042
|
+
const workspace = sourceToken && opts.socketTeamId ? await verifiedSlackWorkspace(sourceToken).catch(() => null) : null;
|
|
1043
|
+
if (signal.aborted)
|
|
1044
|
+
return;
|
|
1045
|
+
const toolSource = workspace && workspace.teamId === opts.socketTeamId && event.user && sourceToken
|
|
1046
|
+
? { teamId: workspace.teamId, actorId: event.user, destination: target, credentialKey: slackCredentialKey(sourceToken),
|
|
1047
|
+
...(typeof event.action_token === 'string' ? { actionToken: event.action_token } : {}) } : undefined;
|
|
865
1048
|
await slackOrchestrate(target, prompt, displayText, signal, {
|
|
1049
|
+
...(toolSource ? { toolSource } : {}),
|
|
866
1050
|
...(opts.eventKey ? { eventKey: opts.eventKey } : {}),
|
|
867
1051
|
...(opts.reservationGeneration !== undefined
|
|
868
1052
|
? { reservationGeneration: opts.reservationGeneration } : {}),
|
|
@@ -870,8 +1054,9 @@ async function runSlackMessageEvent(event, target, text, signal, opts, commitPre
|
|
|
870
1054
|
// The ACK anchor is the user's own message. Only this caller has the raw
|
|
871
1055
|
// event, so the scope inputs are resolved here rather than re-derived.
|
|
872
1056
|
...(event.ts ? { ackTs: event.ts } : {}),
|
|
1057
|
+
...(event.user ? { recipientUserId: event.user } : {}),
|
|
873
1058
|
isDirect: event.channel_type === 'im',
|
|
874
|
-
isMention: event
|
|
1059
|
+
isMention: isSlackMention(event, selfUserId),
|
|
875
1060
|
});
|
|
876
1061
|
}
|
|
877
1062
|
/**
|
|
@@ -909,7 +1094,7 @@ async function buildInboundContextBlock(event, identity, signal, opts = {}, comm
|
|
|
909
1094
|
// A top-level channel message has no thread to read; the channel's own
|
|
910
1095
|
// recent history (ending before this event) is its context (#518 r2).
|
|
911
1096
|
const isTopLevelChannel = !threadTs
|
|
912
|
-
&& (event.channel_type === 'channel' || event.channel_type === 'group');
|
|
1097
|
+
&& (event.channel_type === 'channel' || event.channel_type === 'group' || event.channel_type === 'mpim');
|
|
913
1098
|
// Independent lookups: serial would double the round trips inside a
|
|
914
1099
|
// deadline that exists to stay small.
|
|
915
1100
|
const [conversation, thread, channelHistory] = await Promise.all([
|
|
@@ -942,7 +1127,7 @@ async function buildInboundContextBlock(event, identity, signal, opts = {}, comm
|
|
|
942
1127
|
if (!prior.length)
|
|
943
1128
|
return block;
|
|
944
1129
|
const authorIds = prior.map(message => message.user || message.botId || '').filter(Boolean);
|
|
945
|
-
const preamble = buildThreadPreamble(formatHistoryForAgent(prior, selfUserId, cachedNameMap(teamId, authorIds)), thread?.replyCount ?? prior.length);
|
|
1130
|
+
const preamble = buildThreadPreamble(formatHistoryForAgent(prior, selfUserId, cachedNameMap(teamId, authorIds)), thread?.replyCount ?? prior.length, thread);
|
|
946
1131
|
if (!preamble)
|
|
947
1132
|
return block;
|
|
948
1133
|
// History is actually going into the prompt: the claim is spent.
|
|
@@ -1057,6 +1242,11 @@ export async function handleSlackEnvelope(envelope, approvalTransport = slackApp
|
|
|
1057
1242
|
: '';
|
|
1058
1243
|
const parsed = parseApprovalCallbackData(actionId);
|
|
1059
1244
|
if (!parsed) {
|
|
1245
|
+
const token = getSlackSendClient().token;
|
|
1246
|
+
if (token) {
|
|
1247
|
+
const { consumeSlackInteractionCallback } = await import('./actions-interactions.js');
|
|
1248
|
+
await consumeSlackInteractionCallback(payload, token);
|
|
1249
|
+
}
|
|
1060
1250
|
log.info('[slack:interactive] received (not an approval action)');
|
|
1061
1251
|
return;
|
|
1062
1252
|
}
|
|
@@ -1114,7 +1304,7 @@ export async function handleSlackEnvelope(envelope, approvalTransport = slackApp
|
|
|
1114
1304
|
const prefetchOwner = preResolvedScope
|
|
1115
1305
|
? getSessionOwnershipGeneration(preResolvedScope)
|
|
1116
1306
|
: undefined;
|
|
1117
|
-
if (event
|
|
1307
|
+
if (isSlackMention(event, selfUserId) && event.channel) {
|
|
1118
1308
|
// A top-level mention starts a thread the bot will parent, so the whole
|
|
1119
1309
|
// thread belongs to it. A mention INSIDE an existing thread is an
|
|
1120
1310
|
// invitation into someone else's conversation, and only that (#400).
|
|
@@ -1137,7 +1327,7 @@ export async function handleSlackEnvelope(envelope, approvalTransport = slackApp
|
|
|
1137
1327
|
// between this function's entry and this line, so the test-and-set is atomic
|
|
1138
1328
|
// against a second envelope in the same tick.
|
|
1139
1329
|
const prefetchSubject = event.thread_ts
|
|
1140
|
-
?? ((event.channel_type === 'channel' || event.channel_type === 'group') ? '' : undefined);
|
|
1330
|
+
?? ((event.channel_type === 'channel' || event.channel_type === 'group' || event.channel_type === 'mpim') ? '' : undefined);
|
|
1141
1331
|
const prefetchToken = prefetchSubject !== undefined && prefetchOwner
|
|
1142
1332
|
? claimThreadPrefetch(event.channel || '', prefetchSubject, prefetchOwner)
|
|
1143
1333
|
: 0;
|
|
@@ -1150,7 +1340,10 @@ export async function handleSlackEnvelope(envelope, approvalTransport = slackApp
|
|
|
1150
1340
|
// app_mention 봉투에는 files 가 없고, 첨부를 가진 message 사본은 위
|
|
1151
1341
|
// shouldProcessSlackEvent 에서 mention_via_app_mention 으로 드롭된다.
|
|
1152
1342
|
// 그래서 멘션과 함께 올린 파일은 여기서 되찾지 않으면 영영 사라진다.
|
|
1153
|
-
|
|
1343
|
+
// app_mention envelopes drop files, so they need the history recovery.
|
|
1344
|
+
// A message.mpim mention already carries its files inline; when it has
|
|
1345
|
+
// none there is no twin envelope to recover from, only a wasted call.
|
|
1346
|
+
if (!hasFiles && event.type === 'app_mention' && isSlackMention(event, selfUserId) && event.channel && event.ts) {
|
|
1154
1347
|
const recoverToken = getSlackSendClient().token;
|
|
1155
1348
|
if (recoverToken) {
|
|
1156
1349
|
const recovered = await recoverSlackAttachments(recoverToken, event.channel, event.ts, event.thread_ts ? { threadTs: event.thread_ts } : {});
|
|
@@ -1176,6 +1369,7 @@ export async function handleSlackEnvelope(envelope, approvalTransport = slackApp
|
|
|
1176
1369
|
return;
|
|
1177
1370
|
}
|
|
1178
1371
|
prefetchHandedOff = enqueueSlackIngress(slackIngressLaneKey(target), signal => processSlackMessageEvent(event, target, text, signal, {
|
|
1372
|
+
...(typeof envelope.payload?.['team_id'] === 'string' ? { socketTeamId: envelope.payload['team_id'] } : {}),
|
|
1179
1373
|
prefetchToken,
|
|
1180
1374
|
...(prefetchOwner ? { prefetchOwner } : {}),
|
|
1181
1375
|
preResolvedScope,
|
|
@@ -1237,11 +1431,14 @@ async function runSlackInit(ctx) {
|
|
|
1237
1431
|
// init it belongs to.
|
|
1238
1432
|
const generation = ++lifecycleGeneration;
|
|
1239
1433
|
await disposeSlackRuntime();
|
|
1434
|
+
if (generation !== lifecycleGeneration)
|
|
1435
|
+
return transportNotStarted('superseded');
|
|
1240
1436
|
const sc = settings["slack"];
|
|
1241
1437
|
if (!sc?.enabled || !sc?.botToken) {
|
|
1242
1438
|
log.info('[slack] ⏭️ Slack pending (disabled or no bot token)');
|
|
1243
1439
|
return transportNotStarted('not_configured');
|
|
1244
1440
|
}
|
|
1441
|
+
slackStopping = false;
|
|
1245
1442
|
if (!sc.appToken) {
|
|
1246
1443
|
// Outbound still works via the send transport; only inbound needs
|
|
1247
1444
|
// the app-level token. Say so precisely instead of "failed".
|
|
@@ -1515,6 +1712,13 @@ function startSlackAutoJoin(sc, generation) {
|
|
|
1515
1712
|
* `shutdownSlack` invalidates in-flight initializations.
|
|
1516
1713
|
*/
|
|
1517
1714
|
async function disposeSlackRuntime() {
|
|
1715
|
+
slackStopping = true;
|
|
1716
|
+
slackProgressRestorer.abort();
|
|
1717
|
+
for (const seal of [...slackProgressSealers])
|
|
1718
|
+
seal();
|
|
1719
|
+
// Begin both cancellation paths before ingress drain can consume the server deadline.
|
|
1720
|
+
const outboundDrain = slackOutboundRegistry.drain();
|
|
1721
|
+
const progressDrain = slackProgressRegistry.drain(SLACK_PROGRESS_DRAIN_MS);
|
|
1518
1722
|
await resetSlackIngress();
|
|
1519
1723
|
// Wakes the pacing sleeps immediately instead of letting a teardown wait
|
|
1520
1724
|
// out a 3-second gap between conversations.list pages.
|
|
@@ -1537,27 +1741,7 @@ async function disposeSlackRuntime() {
|
|
|
1537
1741
|
removeBroadcastListener(forwarderHandler);
|
|
1538
1742
|
forwarderHandler = null;
|
|
1539
1743
|
}
|
|
1540
|
-
|
|
1541
|
-
// timeout against a transport that no longer exists. Awaited and bounded:
|
|
1542
|
-
// dropping the callbacks (as this did) leaves the notice claiming the agent
|
|
1543
|
-
// is still working, while an unbounded await would hold shutdown open on a
|
|
1544
|
-
// stuck vendor call. The deadline covers the worst honest chain and the
|
|
1545
|
-
// signal cancels whatever is left.
|
|
1546
|
-
//
|
|
1547
|
-
// The registry is the single shutdown route, as on Discord. A parallel
|
|
1548
|
-
// waiter set used to hold one closure per queued turn and was cleared only
|
|
1549
|
-
// here, so a long-lived instance retained every historical turn's token,
|
|
1550
|
-
// target, notice and ACK closure. It also stripped the drain's abort signal:
|
|
1551
|
-
// a QueueNotice pins the signal from its FIRST close, so a waiter closing
|
|
1552
|
-
// without one made the later shutdown signal unreachable.
|
|
1553
|
-
// Abort in-flight answer sends FIRST (#417 review): a queued waiter that
|
|
1554
|
-
// already claimed its terminal is mid-send, and the notice drain below
|
|
1555
|
-
// awaits that same promise — un-aborted, a hung vendor POST would eat the
|
|
1556
|
-
// whole notice budget and blow past the server's 5s force-exit. Aborting
|
|
1557
|
-
// first makes the hung send settle immediately (as slack_send_aborted,
|
|
1558
|
-
// never a vendor failure), so the notice drain only pays for cleanup.
|
|
1559
|
-
await slackOutboundRegistry.drain();
|
|
1560
|
-
await slackNoticeRegistry.drain(SLACK_NOTICE_DRAIN_MS);
|
|
1744
|
+
await Promise.allSettled([outboundDrain, progressDrain]);
|
|
1561
1745
|
pendingQueueRequestIds.clear();
|
|
1562
1746
|
socketClient?.stop();
|
|
1563
1747
|
socketClient = null;
|