experimental-ash 0.10.0 → 0.10.1
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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# experimental-ash
|
|
2
2
|
|
|
3
|
+
## 0.10.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 05ab89c: fix(slack): eliminate duplicate replies on follow-up mentions in a thread
|
|
8
|
+
|
|
9
|
+
`slackChannel` registered its `chat.onNewMention` listener inside the route handler, but the chat SDK's `onNewMention` _pushes_ handlers to an internal list (it does not replace). On warm serverless workers the cached `chat` instance accumulated one listener per inbound webhook, so the N-th `app_mention` dispatched N independent agent turns. Symptom: the first mention in a thread replied once, the next replied twice, the next three times, etc. — reset only by a cold start.
|
|
10
|
+
|
|
11
|
+
Registration is now done once per `chat` instance inside `getChat()`. The route handler threads its per-request `send` / `waitUntil` to the listener through a per-`slackChannel` `AsyncLocalStorage` — `requestContext.run({ send, waitUntil }, () => chat.webhooks.slack(req, ...))`. A naive shared module slot would race under concurrent webhook deliveries (the chat SDK yields the event loop on `await req.text()` inside `webhooks.slack`, so a second request can overwrite the slot before the first listener fires, causing request A's mention to dispatch with request B's `send`). ALS binds the store to the async call tree, so each concurrent route invocation gets its own isolated context.
|
|
12
|
+
|
|
13
|
+
The mention dispatch also now runs under `waitUntil(send(...).catch(log))` instead of `await send(...)`, mirroring the interaction path. This lets the webhook return `200 OK` within Slack's ~3s ACK window even on cold starts, avoiding the secondary duplication mode where Slack retries the same `app_mention` payload after a slow ACK.
|
|
14
|
+
|
|
3
15
|
## 0.10.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -6,7 +6,7 @@ import { ASH_PACKAGE_NAME } from "#package-name.js";
|
|
|
6
6
|
let cachedPackageInfo;
|
|
7
7
|
// The package build stamps the published version into `dist` so bundled
|
|
8
8
|
// deployments can still report package metadata without resolving package.json.
|
|
9
|
-
const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.10.
|
|
9
|
+
const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.10.1";
|
|
10
10
|
const BUNDLED_FALLBACK_PACKAGE_VERSION_PLACEHOLDER = "__ASH_PACKAGE_VERSION_PLACEHOLDER__";
|
|
11
11
|
const WORKFLOW_MODULE_ALIASES = {
|
|
12
12
|
"workflow/api": "src/compiled/@workflow/core/runtime.js",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { createSlackAdapter } from "#compiled/@chat-adapter/slack/index.js";
|
|
2
3
|
import { createMemoryState } from "#compiled/@chat-adapter/state-memory/index.js";
|
|
3
4
|
import { ThreadImpl, } from "#compiled/chat/index.js";
|
|
@@ -5,7 +6,7 @@ import { createLogger } from "#internal/logging.js";
|
|
|
5
6
|
import { buildSlackTurnMessage, collectSlackFileParts, createSlackFetchFile, } from "#public/channels/slack/attachments.js";
|
|
6
7
|
import { deriveHitlResponse, isHitlAction, renderInputRequestBlocks, } from "#public/channels/slack/hitl.js";
|
|
7
8
|
import { mergeUploadPolicy } from "#public/channels/upload-policy.js";
|
|
8
|
-
import { defineChannel, POST } from "#public/definitions/defineChannel.js";
|
|
9
|
+
import { defineChannel, POST, } from "#public/definitions/defineChannel.js";
|
|
9
10
|
const log = createLogger("slack.channel");
|
|
10
11
|
function decodeThreadId(id) {
|
|
11
12
|
const parts = id.replace(/^slack:/u, "").split(":");
|
|
@@ -141,6 +142,14 @@ export function slackChannel(config = {}) {
|
|
|
141
142
|
botToken: config.credentials?.botToken,
|
|
142
143
|
});
|
|
143
144
|
const stateAdapter = config.stateAdapter ?? createMemoryState();
|
|
145
|
+
// Threads per-request `send`/`waitUntil` to the once-registered
|
|
146
|
+
// `onNewMention` listener. A shared mutable slot would race under
|
|
147
|
+
// concurrent webhook deliveries — `webhooks.slack` yields on
|
|
148
|
+
// `await req.text()`, so request B could overwrite A's slot before A's
|
|
149
|
+
// listener fires. ALS scopes the store to each route's async call tree.
|
|
150
|
+
// Allowlisted in `scripts/guard-agents-rules-baseline.json` (rule 19);
|
|
151
|
+
// this is channel-local request state, not Ash runtime state.
|
|
152
|
+
const requestContext = new AsyncLocalStorage();
|
|
144
153
|
let chatPromise = null;
|
|
145
154
|
async function getChat() {
|
|
146
155
|
if (chatPromise)
|
|
@@ -166,6 +175,65 @@ export function slackChannel(config = {}) {
|
|
|
166
175
|
userName: config.botName ?? "ash-agent",
|
|
167
176
|
});
|
|
168
177
|
await chat.initialize();
|
|
178
|
+
// Register exactly once per chat instance — the chat SDK's
|
|
179
|
+
// `onNewMention` pushes handlers (does not replace), so registering
|
|
180
|
+
// on every webhook accumulates listeners on warm workers and the
|
|
181
|
+
// N-th `app_mention` dispatches N times.
|
|
182
|
+
chat.onNewMention(async (thread, message) => {
|
|
183
|
+
const rawEvent = message.raw;
|
|
184
|
+
// Slack sends both `app_mention` and `message.channels` for the same
|
|
185
|
+
// utterance. The Chat SDK dedup relies on in-memory state that doesn't
|
|
186
|
+
// survive serverless invocations, so both events reach this handler.
|
|
187
|
+
// Only process `app_mention` to prevent duplicate runs.
|
|
188
|
+
if (rawEvent?.type !== "app_mention")
|
|
189
|
+
return;
|
|
190
|
+
const ctx = requestContext.getStore();
|
|
191
|
+
if (!ctx) {
|
|
192
|
+
// Defensive: the chat SDK invokes handlers inside the
|
|
193
|
+
// `requestContext.run(...)` tree below, so this should never
|
|
194
|
+
// miss. Log and drop in case a future SDK ever defers
|
|
195
|
+
// invocation (e.g. via a queue or `setImmediate`).
|
|
196
|
+
log.warn("slack mention received but no request context is active");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const teamId = rawEvent.team_id ?? rawEvent.team;
|
|
200
|
+
const slackCtx = {
|
|
201
|
+
thread,
|
|
202
|
+
slack: buildSlackApiHandle(thread, config.credentials?.botToken, teamId),
|
|
203
|
+
};
|
|
204
|
+
const runOpts = config.run ? await config.run(slackCtx, message) : { auth: null };
|
|
205
|
+
if (runOpts === null)
|
|
206
|
+
return;
|
|
207
|
+
if (config.onMention) {
|
|
208
|
+
try {
|
|
209
|
+
await config.onMention(slackCtx, message);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
log.error("onMention handler failed", { error });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const decoded = decodeThreadId(thread.id ?? "");
|
|
216
|
+
const continuationToken = `slack:${decoded.channelId}:${decoded.threadTs}`;
|
|
217
|
+
const fileParts = collectSlackFileParts(message, uploadPolicy);
|
|
218
|
+
const turnMessage = buildSlackTurnMessage(message.text, fileParts);
|
|
219
|
+
// Dispatch via `waitUntil` so the webhook ACKs within Slack's ~3s
|
|
220
|
+
// window even on cold starts. Awaiting `send` here would let a
|
|
221
|
+
// slow dispatch push the response past 3s and trigger Slack
|
|
222
|
+
// retries of the same `app_mention` (which the type filter
|
|
223
|
+
// cannot dedupe). Errors are logged; Slack already got its 200.
|
|
224
|
+
ctx.waitUntil(ctx
|
|
225
|
+
.send(turnMessage, {
|
|
226
|
+
auth: runOpts.auth,
|
|
227
|
+
continuationToken,
|
|
228
|
+
state: {
|
|
229
|
+
serializedThread: thread.toJSON(),
|
|
230
|
+
teamId: teamId ?? null,
|
|
231
|
+
},
|
|
232
|
+
})
|
|
233
|
+
.catch((error) => {
|
|
234
|
+
log.error("mention delivery failed", { error });
|
|
235
|
+
}));
|
|
236
|
+
});
|
|
169
237
|
return { chat };
|
|
170
238
|
})();
|
|
171
239
|
chatPromise = promise;
|
|
@@ -243,44 +311,9 @@ export function slackChannel(config = {}) {
|
|
|
243
311
|
}
|
|
244
312
|
return new Response("ok", { status: 200 });
|
|
245
313
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
// utterance. The Chat SDK dedup relies on in-memory state that doesn't
|
|
250
|
-
// survive serverless invocations, so both events reach this handler.
|
|
251
|
-
// Only process `app_mention` to prevent duplicate runs.
|
|
252
|
-
if (rawEvent?.type !== "app_mention")
|
|
253
|
-
return;
|
|
254
|
-
const teamId = rawEvent.team_id ?? rawEvent.team;
|
|
255
|
-
const slackCtx = {
|
|
256
|
-
thread,
|
|
257
|
-
slack: buildSlackApiHandle(thread, config.credentials?.botToken, teamId),
|
|
258
|
-
};
|
|
259
|
-
const runOpts = config.run ? await config.run(slackCtx, message) : { auth: null };
|
|
260
|
-
if (runOpts === null)
|
|
261
|
-
return;
|
|
262
|
-
if (config.onMention) {
|
|
263
|
-
try {
|
|
264
|
-
await config.onMention(slackCtx, message);
|
|
265
|
-
}
|
|
266
|
-
catch (error) {
|
|
267
|
-
log.error("onMention handler failed", { error });
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
const decoded = decodeThreadId(thread.id ?? "");
|
|
271
|
-
const continuationToken = `slack:${decoded.channelId}:${decoded.threadTs}`;
|
|
272
|
-
const fileParts = collectSlackFileParts(message, uploadPolicy);
|
|
273
|
-
const turnMessage = buildSlackTurnMessage(message.text, fileParts);
|
|
274
|
-
await send(turnMessage, {
|
|
275
|
-
auth: runOpts.auth,
|
|
276
|
-
continuationToken,
|
|
277
|
-
state: {
|
|
278
|
-
serializedThread: thread.toJSON(),
|
|
279
|
-
teamId: teamId ?? null,
|
|
280
|
-
},
|
|
281
|
-
});
|
|
282
|
-
});
|
|
283
|
-
return await chat.webhooks.slack(req, { waitUntil });
|
|
314
|
+
// Scope `send` / `waitUntil` to this request's async call tree so
|
|
315
|
+
// the `onNewMention` listener picks them up via `getStore()`.
|
|
316
|
+
return await requestContext.run({ send, waitUntil }, () => chat.webhooks.slack(req, { waitUntil }));
|
|
284
317
|
}),
|
|
285
318
|
],
|
|
286
319
|
async receive(input, { send }) {
|