@desplega.ai/agent-swarm 1.73.2 → 1.73.4

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/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.73.2",
5
+ "version": "1.73.4",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
@@ -2914,6 +2914,112 @@
2914
2914
  }
2915
2915
  }
2916
2916
  },
2917
+ "/api/memory/list": {
2918
+ "post": {
2919
+ "summary": "List or semantically search memories across all agents (debug/admin)",
2920
+ "tags": [
2921
+ "Memory"
2922
+ ],
2923
+ "security": [
2924
+ {
2925
+ "bearerAuth": []
2926
+ }
2927
+ ],
2928
+ "requestBody": {
2929
+ "content": {
2930
+ "application/json": {
2931
+ "schema": {
2932
+ "type": "object",
2933
+ "properties": {
2934
+ "query": {
2935
+ "type": "string",
2936
+ "description": "Natural-language query. If present, runs semantic search; otherwise lists by recency."
2937
+ },
2938
+ "agentId": {
2939
+ "type": "string",
2940
+ "format": "uuid",
2941
+ "description": "Filter to a single agent. Omit for all."
2942
+ },
2943
+ "scope": {
2944
+ "type": "string",
2945
+ "enum": [
2946
+ "agent",
2947
+ "swarm",
2948
+ "all"
2949
+ ],
2950
+ "default": "all"
2951
+ },
2952
+ "source": {
2953
+ "type": "string",
2954
+ "enum": [
2955
+ "manual",
2956
+ "file_index",
2957
+ "session_summary",
2958
+ "task_completion"
2959
+ ]
2960
+ },
2961
+ "sourcePath": {
2962
+ "type": "string",
2963
+ "description": "Substring match against sourcePath (case-insensitive). Useful for file_index memories."
2964
+ },
2965
+ "limit": {
2966
+ "type": "integer",
2967
+ "minimum": 1,
2968
+ "maximum": 100,
2969
+ "default": 20
2970
+ },
2971
+ "offset": {
2972
+ "type": "integer",
2973
+ "minimum": 0,
2974
+ "default": 0
2975
+ }
2976
+ }
2977
+ }
2978
+ }
2979
+ }
2980
+ },
2981
+ "responses": {
2982
+ "200": {
2983
+ "description": "Memory list / search results"
2984
+ },
2985
+ "400": {
2986
+ "description": "Validation error"
2987
+ }
2988
+ }
2989
+ }
2990
+ },
2991
+ "/api/memory/{id}": {
2992
+ "delete": {
2993
+ "summary": "Delete a single memory by ID (debug/admin)",
2994
+ "tags": [
2995
+ "Memory"
2996
+ ],
2997
+ "security": [
2998
+ {
2999
+ "bearerAuth": []
3000
+ }
3001
+ ],
3002
+ "parameters": [
3003
+ {
3004
+ "schema": {
3005
+ "type": "string",
3006
+ "format": "uuid"
3007
+ },
3008
+ "required": true,
3009
+ "name": "id",
3010
+ "in": "path"
3011
+ }
3012
+ ],
3013
+ "responses": {
3014
+ "200": {
3015
+ "description": "Memory deleted"
3016
+ },
3017
+ "404": {
3018
+ "description": "Memory not found"
3019
+ }
3020
+ }
3021
+ }
3022
+ },
2917
3023
  "/api/prompt-templates/resolved": {
2918
3024
  "get": {
2919
3025
  "summary": "Resolve a prompt template for a given event type and scope chain",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.73.2",
3
+ "version": "1.73.4",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
package/src/be/db.ts CHANGED
@@ -1196,8 +1196,8 @@ export function getAllTasks(filters?: TaskFilters): AgentTask[] {
1196
1196
  }
1197
1197
 
1198
1198
  if (filters?.search) {
1199
- conditions.push("task LIKE ?");
1200
- params.push(`%${filters.search}%`);
1199
+ conditions.push("(task LIKE ? OR id LIKE ?)");
1200
+ params.push(`%${filters.search}%`, `%${filters.search}%`);
1201
1201
  }
1202
1202
 
1203
1203
  // New filters
@@ -1274,8 +1274,8 @@ export function getTasksCount(filters?: Omit<TaskFilters, "limit" | "readyOnly">
1274
1274
  }
1275
1275
 
1276
1276
  if (filters?.search) {
1277
- conditions.push("task LIKE ?");
1278
- params.push(`%${filters.search}%`);
1277
+ conditions.push("(task LIKE ? OR id LIKE ?)");
1278
+ params.push(`%${filters.search}%`, `%${filters.search}%`);
1279
1279
  }
1280
1280
 
1281
1281
  if (filters?.unassigned) {
@@ -1571,6 +1571,15 @@ export function findCompletedTaskInThread(
1571
1571
 
1572
1572
  export function completeTask(id: string, output?: string): AgentTask | null {
1573
1573
  const oldTask = getTaskById(id);
1574
+ if (!oldTask) return null;
1575
+
1576
+ // Idempotency guard: don't re-complete a task already in a terminal state.
1577
+ // Mirrors cancelTask. Prevents duplicate task.completed events, duplicate
1578
+ // log entries, and duplicate follow-up tasks when multiple sessions race.
1579
+ if (["completed", "failed", "cancelled"].includes(oldTask.status)) {
1580
+ return null;
1581
+ }
1582
+
1574
1583
  const finishedAt = new Date().toISOString();
1575
1584
  let row = taskQueries.updateStatus().get("completed", finishedAt, id);
1576
1585
  if (!row) return null;
@@ -1607,6 +1616,15 @@ export function completeTask(id: string, output?: string): AgentTask | null {
1607
1616
 
1608
1617
  export function failTask(id: string, reason: string): AgentTask | null {
1609
1618
  const oldTask = getTaskById(id);
1619
+ if (!oldTask) return null;
1620
+
1621
+ // Idempotency guard: don't re-fail a task already in a terminal state.
1622
+ // Mirrors cancelTask / completeTask. Prevents duplicate task.failed events
1623
+ // and duplicate follow-up tasks when multiple sessions race.
1624
+ if (["completed", "failed", "cancelled"].includes(oldTask.status)) {
1625
+ return null;
1626
+ }
1627
+
1610
1628
  const finishedAt = new Date().toISOString();
1611
1629
  const row = taskQueries.setFailure().get(reason, finishedAt, id);
1612
1630
  if (row && oldTask) {
@@ -69,6 +69,52 @@ const reEmbedMemory = route({
69
69
  },
70
70
  });
71
71
 
72
+ const listMemory = route({
73
+ method: "post",
74
+ path: "/api/memory/list",
75
+ pattern: ["api", "memory", "list"],
76
+ summary: "List or semantically search memories across all agents (debug/admin)",
77
+ tags: ["Memory"],
78
+ auth: { apiKey: true },
79
+ body: z.object({
80
+ query: z
81
+ .string()
82
+ .optional()
83
+ .describe(
84
+ "Natural-language query. If present, runs semantic search; otherwise lists by recency.",
85
+ ),
86
+ agentId: z.string().uuid().optional().describe("Filter to a single agent. Omit for all."),
87
+ scope: z.enum(["agent", "swarm", "all"]).default("all"),
88
+ source: AgentMemorySourceSchema.optional(),
89
+ sourcePath: z
90
+ .string()
91
+ .optional()
92
+ .describe(
93
+ "Substring match against sourcePath (case-insensitive). Useful for file_index memories.",
94
+ ),
95
+ limit: z.number().int().min(1).max(100).default(20),
96
+ offset: z.number().int().min(0).default(0),
97
+ }),
98
+ responses: {
99
+ 200: { description: "Memory list / search results" },
100
+ 400: { description: "Validation error" },
101
+ },
102
+ });
103
+
104
+ const deleteMemoryById = route({
105
+ method: "delete",
106
+ path: "/api/memory/{id}",
107
+ pattern: ["api", "memory", null],
108
+ summary: "Delete a single memory by ID (debug/admin)",
109
+ tags: ["Memory"],
110
+ auth: { apiKey: true },
111
+ params: z.object({ id: z.string().uuid() }),
112
+ responses: {
113
+ 200: { description: "Memory deleted" },
114
+ 404: { description: "Memory not found" },
115
+ },
116
+ });
117
+
72
118
  // ─── Handler ─────────────────────────────────────────────────────────────────
73
119
 
74
120
  export async function handleMemory(
@@ -182,6 +228,132 @@ export async function handleMemory(
182
228
  return true;
183
229
  }
184
230
 
231
+ if (listMemory.match(req.method, pathSegments)) {
232
+ const parsed = await listMemory.parse(req, res, pathSegments, new URLSearchParams());
233
+ if (!parsed) return true;
234
+
235
+ const { query, agentId, scope, source, sourcePath, limit, offset } = parsed.body;
236
+ const store = getMemoryStore();
237
+ const pathNeedle = sourcePath?.trim().toLowerCase();
238
+ const matchesPath = (p: string | null) =>
239
+ !pathNeedle || (p?.toLowerCase().includes(pathNeedle) ?? false);
240
+
241
+ try {
242
+ if (query && query.trim().length > 0) {
243
+ const provider = getEmbeddingProvider();
244
+ const queryEmbedding = await provider.embed(query.trim());
245
+
246
+ if (!queryEmbedding) {
247
+ json(res, { results: [], total: 0, mode: "semantic" });
248
+ return true;
249
+ }
250
+
251
+ const candidateLimit = Math.min(limit, 100) * CANDIDATE_SET_MULTIPLIER;
252
+ let candidates = store.search(queryEmbedding, agentId ?? "", {
253
+ scope,
254
+ limit: candidateLimit,
255
+ isLead: true,
256
+ source,
257
+ });
258
+ if (agentId) {
259
+ candidates = candidates.filter((c) => c.agentId === agentId);
260
+ }
261
+ if (pathNeedle) {
262
+ candidates = candidates.filter((c) => matchesPath(c.sourcePath));
263
+ }
264
+ const ranked = rerank(candidates, { limit: Math.min(limit, 100) });
265
+
266
+ json(res, {
267
+ results: ranked.map((r) => ({
268
+ id: r.id,
269
+ name: r.name,
270
+ content: r.content,
271
+ agentId: r.agentId,
272
+ scope: r.scope,
273
+ source: r.source,
274
+ similarity: r.similarity,
275
+ createdAt: r.createdAt,
276
+ accessedAt: r.accessedAt,
277
+ accessCount: r.accessCount ?? 0,
278
+ expiresAt: r.expiresAt ?? null,
279
+ embeddingModel: r.embeddingModel ?? null,
280
+ sourceTaskId: r.sourceTaskId,
281
+ sourcePath: r.sourcePath,
282
+ chunkIndex: r.chunkIndex,
283
+ totalChunks: r.totalChunks,
284
+ tags: r.tags,
285
+ })),
286
+ total: ranked.length,
287
+ mode: "semantic",
288
+ });
289
+ return true;
290
+ }
291
+
292
+ // When filtering by sourcePath, over-fetch then post-filter so the visible
293
+ // page isn't gutted by the in-memory filter.
294
+ const fetchLimit = pathNeedle
295
+ ? Math.min(500, Math.max(limit * 10, 100))
296
+ : Math.min(limit, 100);
297
+ let rows = store.list(agentId ?? "", {
298
+ scope,
299
+ limit: fetchLimit,
300
+ offset,
301
+ isLead: true,
302
+ });
303
+ if (agentId) {
304
+ rows = rows.filter((r) => r.agentId === agentId);
305
+ }
306
+ if (source) {
307
+ rows = rows.filter((r) => r.source === source);
308
+ }
309
+ if (pathNeedle) {
310
+ rows = rows.filter((r) => matchesPath(r.sourcePath));
311
+ }
312
+ rows = rows.slice(0, Math.min(limit, 100));
313
+
314
+ json(res, {
315
+ results: rows.map((r) => ({
316
+ id: r.id,
317
+ name: r.name,
318
+ content: r.content,
319
+ agentId: r.agentId,
320
+ scope: r.scope,
321
+ source: r.source,
322
+ createdAt: r.createdAt,
323
+ accessedAt: r.accessedAt,
324
+ accessCount: r.accessCount ?? 0,
325
+ expiresAt: r.expiresAt ?? null,
326
+ embeddingModel: r.embeddingModel ?? null,
327
+ sourceTaskId: r.sourceTaskId,
328
+ sourcePath: r.sourcePath,
329
+ chunkIndex: r.chunkIndex,
330
+ totalChunks: r.totalChunks,
331
+ tags: r.tags,
332
+ })),
333
+ total: rows.length,
334
+ mode: "list",
335
+ });
336
+ } catch (err) {
337
+ console.error("[memory-list] Error:", (err as Error).message);
338
+ jsonError(res, "Memory list failed", 500);
339
+ }
340
+ return true;
341
+ }
342
+
343
+ if (deleteMemoryById.match(req.method, pathSegments)) {
344
+ const parsed = await deleteMemoryById.parse(req, res, pathSegments, new URLSearchParams());
345
+ if (!parsed) return true;
346
+
347
+ const store = getMemoryStore();
348
+ const deleted = store.delete(parsed.params.id);
349
+ if (!deleted) {
350
+ jsonError(res, "Memory not found", 404);
351
+ return true;
352
+ }
353
+ json(res, { deleted: true });
354
+ return true;
355
+ }
356
+
185
357
  if (reEmbedMemory.match(req.method, pathSegments)) {
186
358
  const parsed = await reEmbedMemory.parse(req, res, pathSegments, new URLSearchParams());
187
359
  if (!parsed) return true;
@@ -8,6 +8,7 @@ import {
8
8
  import { resolveTemplate } from "../prompts/resolver";
9
9
  import { slackContextKey } from "../tasks/context-key";
10
10
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
11
+ import { wasEventSeen } from "./event-dedup";
11
12
  import { bufferThreadMessage } from "./thread-buffer";
12
13
  // Side-effect import: registers all Slack event templates in the in-memory registry
13
14
  import "./templates";
@@ -40,7 +41,15 @@ export function createAssistant(): Assistant {
40
41
  await saveThreadContext();
41
42
  },
42
43
 
43
- userMessage: async ({ message, say, setStatus, setTitle, getThreadContext }) => {
44
+ userMessage: async ({ message, body, say, setStatus, setTitle, getThreadContext }) => {
45
+ // Slack retries deliveries on 3s timeout / 5xx. Drop duplicates before
46
+ // any task-creation work runs (DES-293).
47
+ const eventId = body?.event_id;
48
+ if (wasEventSeen(eventId)) {
49
+ console.log(`[Slack] dropping Slack retry: event_id=${eventId}`);
50
+ return;
51
+ }
52
+
44
53
  // Wrap setStatus/setTitle to swallow all errors gracefully.
45
54
  // These calls can fail for various reasons (no_permission when the thread
46
55
  // wasn't started by the assistant, network errors, etc.), so we log and continue.
@@ -0,0 +1,123 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { _createTestCache, _resetForTests, wasEventSeen } from "./event-dedup";
3
+
4
+ describe("wasEventSeen (production cache)", () => {
5
+ beforeEach(() => {
6
+ _resetForTests();
7
+ });
8
+ afterEach(() => {
9
+ _resetForTests();
10
+ });
11
+
12
+ test("first call returns false (not seen)", () => {
13
+ expect(wasEventSeen("Ev0001")).toBe(false);
14
+ });
15
+
16
+ test("second call with same id returns true (seen)", () => {
17
+ expect(wasEventSeen("Ev0002")).toBe(false);
18
+ expect(wasEventSeen("Ev0002")).toBe(true);
19
+ });
20
+
21
+ test("different ids do not collide", () => {
22
+ expect(wasEventSeen("Ev0003")).toBe(false);
23
+ expect(wasEventSeen("Ev0004")).toBe(false);
24
+ expect(wasEventSeen("Ev0003")).toBe(true);
25
+ expect(wasEventSeen("Ev0004")).toBe(true);
26
+ });
27
+
28
+ test("undefined / null / empty returns false (no-op)", () => {
29
+ expect(wasEventSeen(undefined)).toBe(false);
30
+ expect(wasEventSeen(null)).toBe(false);
31
+ expect(wasEventSeen("")).toBe(false);
32
+ // Calling again still returns false — empty/null is never inserted.
33
+ expect(wasEventSeen(undefined)).toBe(false);
34
+ expect(wasEventSeen(null)).toBe(false);
35
+ expect(wasEventSeen("")).toBe(false);
36
+ });
37
+
38
+ test("repeated retry deliveries within TTL all return true after first", () => {
39
+ expect(wasEventSeen("Ev_retry")).toBe(false);
40
+ // Slack typically retries 3 times within ~60s
41
+ expect(wasEventSeen("Ev_retry")).toBe(true);
42
+ expect(wasEventSeen("Ev_retry")).toBe(true);
43
+ expect(wasEventSeen("Ev_retry")).toBe(true);
44
+ });
45
+ });
46
+
47
+ describe("isolated test cache (TTL behavior)", () => {
48
+ test("entry expires after TTL elapses", () => {
49
+ const cache = _createTestCache(1000); // 1s TTL
50
+ try {
51
+ expect(cache.wasEventSeen("Ev_ttl")).toBe(false);
52
+ expect(cache.wasEventSeen("Ev_ttl")).toBe(true);
53
+
54
+ cache.advance(500);
55
+ expect(cache.wasEventSeen("Ev_ttl")).toBe(true); // still within TTL
56
+
57
+ cache.advance(600); // total 1100ms — past TTL
58
+ expect(cache.wasEventSeen("Ev_ttl")).toBe(false); // expired, treated as fresh
59
+ expect(cache.wasEventSeen("Ev_ttl")).toBe(true); // re-inserted
60
+ } finally {
61
+ cache.destroy();
62
+ }
63
+ });
64
+
65
+ test("size() reflects active entries after cleanup", () => {
66
+ const cache = _createTestCache(1000);
67
+ try {
68
+ cache.wasEventSeen("a");
69
+ cache.wasEventSeen("b");
70
+ cache.wasEventSeen("c");
71
+ expect(cache.size()).toBe(3);
72
+
73
+ cache.advance(2000); // expire all
74
+ expect(cache.size()).toBe(0);
75
+ } finally {
76
+ cache.destroy();
77
+ }
78
+ });
79
+
80
+ test("zero-length keys still no-op in isolated cache", () => {
81
+ const cache = _createTestCache(1000);
82
+ try {
83
+ expect(cache.wasEventSeen("")).toBe(false);
84
+ expect(cache.wasEventSeen(null)).toBe(false);
85
+ expect(cache.wasEventSeen(undefined)).toBe(false);
86
+ expect(cache.size()).toBe(0);
87
+ } finally {
88
+ cache.destroy();
89
+ }
90
+ });
91
+
92
+ test("custom TTL is honored independently per cache", () => {
93
+ const short = _createTestCache(100);
94
+ const long = _createTestCache(10_000);
95
+ try {
96
+ short.wasEventSeen("x");
97
+ long.wasEventSeen("x");
98
+
99
+ short.advance(200);
100
+ long.advance(200);
101
+ expect(short.wasEventSeen("x")).toBe(false); // expired
102
+ expect(long.wasEventSeen("x")).toBe(true); // still alive
103
+ } finally {
104
+ short.destroy();
105
+ long.destroy();
106
+ }
107
+ });
108
+
109
+ test("simulated double-delivery races: second event returns hit even from concurrent code paths", () => {
110
+ // Simulates: handler A and handler B both fire on the same event_id.
111
+ // The first wins, the second drops.
112
+ const cache = _createTestCache(60_000);
113
+ try {
114
+ const eventId = "EvABCDEF";
115
+ const aSawIt = cache.wasEventSeen(eventId);
116
+ const bSawIt = cache.wasEventSeen(eventId);
117
+ expect(aSawIt).toBe(false);
118
+ expect(bSawIt).toBe(true);
119
+ } finally {
120
+ cache.destroy();
121
+ }
122
+ });
123
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Slack event idempotency cache.
3
+ *
4
+ * Slack's Events API (and Socket Mode delivery) retries event deliveries on
5
+ * 3-second timeouts or 5xx responses. A slow handler — e.g. one that fetches
6
+ * thread context before calling `createTaskExtended` — therefore produces N
7
+ * duplicate task rows from a single user message.
8
+ *
9
+ * The canonical idempotency key for Slack deliveries is `event_id` on the
10
+ * envelope (`body.event_id` in Bolt). It is unique per delivery; retries of
11
+ * the same logical event reuse the same id.
12
+ *
13
+ * This module exposes a single in-memory check-and-insert that returns `false`
14
+ * the first time we see an event_id (caller should proceed) and `true` on
15
+ * subsequent retries within the TTL window — i.e. it answers "was this event
16
+ * already seen?" (default TTL 5 min). Slack's max retry window is 1h with 3
17
+ * retries, but the second retry typically lands within 60s, so 5 min is a
18
+ * safe-but-tight bound.
19
+ *
20
+ * Single-pod-only. The API server (which owns the Slack socket) runs as a
21
+ * single PM2 process; if that ever changes, swap this for a DB-backed table.
22
+ */
23
+
24
+ const DEFAULT_TTL_MS = 300_000; // 5 minutes
25
+ const CLEANUP_INTERVAL_MS = 60_000; // 1 minute
26
+
27
+ interface DedupCache {
28
+ ttlMs: number;
29
+ entries: Map<string, number>;
30
+ cleanupTimer: ReturnType<typeof setInterval> | null;
31
+ }
32
+
33
+ function createCache(ttlMs: number): DedupCache {
34
+ return {
35
+ ttlMs,
36
+ entries: new Map(),
37
+ cleanupTimer: null,
38
+ };
39
+ }
40
+
41
+ const defaultCache: DedupCache = createCache(DEFAULT_TTL_MS);
42
+
43
+ function cleanup(cache: DedupCache, now: number): void {
44
+ for (const [key, expiresAt] of cache.entries) {
45
+ if (expiresAt <= now) {
46
+ cache.entries.delete(key);
47
+ }
48
+ }
49
+ }
50
+
51
+ function ensureCleanupTimer(cache: DedupCache): void {
52
+ if (cache.cleanupTimer) return;
53
+ cache.cleanupTimer = setInterval(() => {
54
+ cleanup(cache, Date.now());
55
+ }, CLEANUP_INTERVAL_MS);
56
+ // Don't keep the event loop alive on this timer.
57
+ if (
58
+ typeof cache.cleanupTimer === "object" &&
59
+ cache.cleanupTimer &&
60
+ "unref" in cache.cleanupTimer
61
+ ) {
62
+ (cache.cleanupTimer as { unref: () => void }).unref();
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Internal check-and-insert. Returns `true` if the event was already seen
68
+ * (caller should drop), `false` if this is the first sighting (caller should
69
+ * proceed). Inserts on a miss so subsequent calls dedup.
70
+ */
71
+ function checkAndInsert(cache: DedupCache, eventId: string, now: number): boolean {
72
+ ensureCleanupTimer(cache);
73
+
74
+ const existing = cache.entries.get(eventId);
75
+ if (existing !== undefined && existing > now) {
76
+ return true; // hit — within TTL
77
+ }
78
+
79
+ cache.entries.set(eventId, now + cache.ttlMs);
80
+ return false;
81
+ }
82
+
83
+ /**
84
+ * Has this Slack event_id been seen recently? Returns `true` if it's a retry
85
+ * we should drop, `false` on the first delivery (and inserts so subsequent
86
+ * calls return true).
87
+ *
88
+ * Pass `null`/`undefined`/empty as a no-op (returns `false`) — defensive against
89
+ * malformed envelopes; we'd rather process once than block legitimate work.
90
+ */
91
+ export function wasEventSeen(eventId: string | undefined | null): boolean {
92
+ if (!eventId) return false;
93
+ return checkAndInsert(defaultCache, eventId, Date.now());
94
+ }
95
+
96
+ /**
97
+ * Test-only helper: build an isolated cache so tests don't leak state into
98
+ * each other or into production.
99
+ */
100
+ export function _createTestCache(ttlMs: number = DEFAULT_TTL_MS): {
101
+ wasEventSeen: (eventId: string | undefined | null) => boolean;
102
+ size: () => number;
103
+ advance: (ms: number) => void;
104
+ destroy: () => void;
105
+ } {
106
+ const cache = createCache(ttlMs);
107
+ let nowOffset = 0;
108
+ const now = () => Date.now() + nowOffset;
109
+
110
+ return {
111
+ wasEventSeen: (eventId) => {
112
+ if (!eventId) return false;
113
+ return checkAndInsert(cache, eventId, now());
114
+ },
115
+ size: () => {
116
+ cleanup(cache, now());
117
+ return cache.entries.size;
118
+ },
119
+ advance: (ms) => {
120
+ nowOffset += ms;
121
+ },
122
+ destroy: () => {
123
+ if (cache.cleanupTimer) {
124
+ clearInterval(cache.cleanupTimer);
125
+ cache.cleanupTimer = null;
126
+ }
127
+ cache.entries.clear();
128
+ },
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Test-only helper to reset the production cache. Do not call from app code.
134
+ */
135
+ export function _resetForTests(): void {
136
+ defaultCache.entries.clear();
137
+ if (defaultCache.cleanupTimer) {
138
+ clearInterval(defaultCache.cleanupTimer);
139
+ defaultCache.cleanupTimer = null;
140
+ }
141
+ }
@@ -14,6 +14,7 @@ import { slackContextKey } from "../tasks/context-key";
14
14
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
15
15
  import { workflowEventBus } from "../workflows/event-bus";
16
16
  import { buildTreeBlocks, type TreeNode } from "./blocks";
17
+ import { wasEventSeen } from "./event-dedup";
17
18
  import type { SlackFile } from "./files";
18
19
  import { extractTaskFromMessage, hasOtherUserMention, routeMessage } from "./router";
19
20
  // Side-effect import: registers all Slack event templates in the in-memory registry
@@ -341,7 +342,15 @@ function checkRateLimit(userId: string): boolean {
341
342
 
342
343
  export function registerMessageHandler(app: App): void {
343
344
  // Handle all message events
344
- app.event("message", async ({ event, client, say }) => {
345
+ app.event("message", async ({ event, body, client, say }) => {
346
+ // Slack retries deliveries on 3s timeout / 5xx. Drop the duplicates
347
+ // before any task-creation work runs (DES-293).
348
+ const eventId = body?.event_id;
349
+ if (wasEventSeen(eventId)) {
350
+ console.log(`[Slack] dropping Slack retry: event_id=${eventId}`);
351
+ return;
352
+ }
353
+
345
354
  const msg = event as MessageEvent;
346
355
 
347
356
  // Ignore message_changed events
@@ -0,0 +1,320 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlinkSync } from "node:fs";
3
+ import {
4
+ cancelTask,
5
+ closeDb,
6
+ completeTask,
7
+ createAgent,
8
+ createTaskExtended,
9
+ failTask,
10
+ getDb,
11
+ getLogsByTaskId,
12
+ getTaskById,
13
+ initDb,
14
+ startTask,
15
+ } from "../be/db";
16
+
17
+ const TEST_DB_PATH = "./test-task-completion-idempotency.sqlite";
18
+
19
+ beforeAll(() => {
20
+ initDb(TEST_DB_PATH);
21
+ });
22
+
23
+ afterAll(() => {
24
+ closeDb();
25
+ try {
26
+ unlinkSync(TEST_DB_PATH);
27
+ unlinkSync(`${TEST_DB_PATH}-wal`);
28
+ unlinkSync(`${TEST_DB_PATH}-shm`);
29
+ } catch {
30
+ // ignore
31
+ }
32
+ });
33
+
34
+ describe("completeTask idempotency", () => {
35
+ test("first call wins; second call on already-completed task returns null", () => {
36
+ const agent = createAgent({
37
+ name: "idempotency-worker-1",
38
+ isLead: false,
39
+ status: "idle",
40
+ capabilities: [],
41
+ });
42
+
43
+ const task = createTaskExtended("Task A", { agentId: agent.id });
44
+ startTask(task.id, agent.id);
45
+
46
+ const first = completeTask(task.id, "first output");
47
+ expect(first).not.toBeNull();
48
+ expect(first!.status).toBe("completed");
49
+ expect(first!.output).toBe("first output");
50
+ const firstFinishedAt = first!.finishedAt;
51
+ expect(firstFinishedAt).toBeTruthy();
52
+
53
+ // Second call should be a no-op and return null
54
+ const second = completeTask(task.id, "second output");
55
+ expect(second).toBeNull();
56
+
57
+ // First-call-wins: original output and finishedAt preserved
58
+ const fresh = getTaskById(task.id);
59
+ expect(fresh!.status).toBe("completed");
60
+ expect(fresh!.output).toBe("first output");
61
+ expect(fresh!.finishedAt).toBe(firstFinishedAt);
62
+ });
63
+
64
+ test("does not re-emit task_status_change log on duplicate completion", () => {
65
+ const agent = createAgent({
66
+ name: "idempotency-worker-2",
67
+ isLead: false,
68
+ status: "idle",
69
+ capabilities: [],
70
+ });
71
+
72
+ const task = createTaskExtended("Task B", { agentId: agent.id });
73
+ startTask(task.id, agent.id);
74
+
75
+ completeTask(task.id, "done");
76
+ const logsAfterFirst = getLogsByTaskId(task.id);
77
+ const completedLogsAfterFirst = logsAfterFirst.filter(
78
+ (l) => l.eventType === "task_status_change" && l.newValue === "completed",
79
+ );
80
+ expect(completedLogsAfterFirst.length).toBe(1);
81
+
82
+ // Second completion should not log another status-change row
83
+ completeTask(task.id, "done again");
84
+ const logsAfterSecond = getLogsByTaskId(task.id);
85
+ const completedLogsAfterSecond = logsAfterSecond.filter(
86
+ (l) => l.eventType === "task_status_change" && l.newValue === "completed",
87
+ );
88
+ expect(completedLogsAfterSecond.length).toBe(1);
89
+ });
90
+
91
+ test("returns null when called on a failed task (cross-terminal)", () => {
92
+ const agent = createAgent({
93
+ name: "idempotency-worker-3",
94
+ isLead: false,
95
+ status: "idle",
96
+ capabilities: [],
97
+ });
98
+
99
+ const task = createTaskExtended("Task C", { agentId: agent.id });
100
+ startTask(task.id, agent.id);
101
+ failTask(task.id, "boom");
102
+
103
+ const result = completeTask(task.id, "trying to complete a failed task");
104
+ expect(result).toBeNull();
105
+
106
+ // Original failed status preserved
107
+ const fresh = getTaskById(task.id);
108
+ expect(fresh!.status).toBe("failed");
109
+ expect(fresh!.failureReason).toBe("boom");
110
+ });
111
+
112
+ test("returns null when called on a cancelled task", () => {
113
+ const agent = createAgent({
114
+ name: "idempotency-worker-4",
115
+ isLead: false,
116
+ status: "idle",
117
+ capabilities: [],
118
+ });
119
+
120
+ const task = createTaskExtended("Task D", { agentId: agent.id });
121
+ startTask(task.id, agent.id);
122
+ cancelTask(task.id, "user cancelled");
123
+
124
+ const result = completeTask(task.id, "trying to complete a cancelled task");
125
+ expect(result).toBeNull();
126
+
127
+ const fresh = getTaskById(task.id);
128
+ expect(fresh!.status).toBe("cancelled");
129
+ });
130
+
131
+ test("returns null for non-existent task", () => {
132
+ const result = completeTask("00000000-0000-0000-0000-000000000000", "x");
133
+ expect(result).toBeNull();
134
+ });
135
+ });
136
+
137
+ describe("failTask idempotency", () => {
138
+ test("first call wins; second call on already-failed task returns null", () => {
139
+ const agent = createAgent({
140
+ name: "fail-idempotency-1",
141
+ isLead: false,
142
+ status: "idle",
143
+ capabilities: [],
144
+ });
145
+
146
+ const task = createTaskExtended("Fail Task A", { agentId: agent.id });
147
+ startTask(task.id, agent.id);
148
+
149
+ const first = failTask(task.id, "original reason");
150
+ expect(first).not.toBeNull();
151
+ expect(first!.status).toBe("failed");
152
+ expect(first!.failureReason).toBe("original reason");
153
+ const firstFinishedAt = first!.finishedAt;
154
+ expect(firstFinishedAt).toBeTruthy();
155
+
156
+ const second = failTask(task.id, "second reason");
157
+ expect(second).toBeNull();
158
+
159
+ const fresh = getTaskById(task.id);
160
+ expect(fresh!.status).toBe("failed");
161
+ expect(fresh!.failureReason).toBe("original reason");
162
+ expect(fresh!.finishedAt).toBe(firstFinishedAt);
163
+ });
164
+
165
+ test("does not re-emit task_status_change log on duplicate failure", () => {
166
+ const agent = createAgent({
167
+ name: "fail-idempotency-2",
168
+ isLead: false,
169
+ status: "idle",
170
+ capabilities: [],
171
+ });
172
+
173
+ const task = createTaskExtended("Fail Task B", { agentId: agent.id });
174
+ startTask(task.id, agent.id);
175
+
176
+ failTask(task.id, "boom");
177
+ const logsAfterFirst = getLogsByTaskId(task.id);
178
+ const failedLogsAfterFirst = logsAfterFirst.filter(
179
+ (l) => l.eventType === "task_status_change" && l.newValue === "failed",
180
+ );
181
+ expect(failedLogsAfterFirst.length).toBe(1);
182
+
183
+ failTask(task.id, "boom again");
184
+ const logsAfterSecond = getLogsByTaskId(task.id);
185
+ const failedLogsAfterSecond = logsAfterSecond.filter(
186
+ (l) => l.eventType === "task_status_change" && l.newValue === "failed",
187
+ );
188
+ expect(failedLogsAfterSecond.length).toBe(1);
189
+ });
190
+
191
+ test("returns null when called on a completed task", () => {
192
+ const agent = createAgent({
193
+ name: "fail-idempotency-3",
194
+ isLead: false,
195
+ status: "idle",
196
+ capabilities: [],
197
+ });
198
+
199
+ const task = createTaskExtended("Fail Task C", { agentId: agent.id });
200
+ startTask(task.id, agent.id);
201
+ completeTask(task.id, "all good");
202
+
203
+ const result = failTask(task.id, "now fail it");
204
+ expect(result).toBeNull();
205
+
206
+ const fresh = getTaskById(task.id);
207
+ expect(fresh!.status).toBe("completed");
208
+ expect(fresh!.output).toBe("all good");
209
+ });
210
+
211
+ test("returns null when called on a cancelled task", () => {
212
+ const agent = createAgent({
213
+ name: "fail-idempotency-4",
214
+ isLead: false,
215
+ status: "idle",
216
+ capabilities: [],
217
+ });
218
+
219
+ const task = createTaskExtended("Fail Task D", { agentId: agent.id });
220
+ startTask(task.id, agent.id);
221
+ cancelTask(task.id, "user cancelled");
222
+
223
+ const result = failTask(task.id, "now fail it");
224
+ expect(result).toBeNull();
225
+
226
+ const fresh = getTaskById(task.id);
227
+ expect(fresh!.status).toBe("cancelled");
228
+ });
229
+
230
+ test("returns null for non-existent task", () => {
231
+ const result = failTask("00000000-0000-0000-0000-000000000000", "x");
232
+ expect(result).toBeNull();
233
+ });
234
+ });
235
+
236
+ describe("store-progress idempotency on terminal status (integration via DB layer)", () => {
237
+ // The store-progress MCP tool short-circuits on terminal status before any
238
+ // side-effects (event emission, memory write, follow-up task, BU ensure).
239
+ // The implementation reuses the same DB-layer guards (completeTask/failTask
240
+ // returning null on terminal state), so these tests verify the underlying
241
+ // contract that store-progress relies on.
242
+
243
+ test("completing an already-completed task is a no-op at the DB layer", () => {
244
+ const agent = createAgent({
245
+ name: "sp-idempotency-1",
246
+ isLead: false,
247
+ status: "idle",
248
+ capabilities: [],
249
+ });
250
+
251
+ const task = createTaskExtended("SP Task A", { agentId: agent.id });
252
+ startTask(task.id, agent.id);
253
+ completeTask(task.id, "first output");
254
+
255
+ // Snapshot the row state
256
+ const snapshot = getTaskById(task.id);
257
+ const snapshotLogs = getLogsByTaskId(task.id).length;
258
+
259
+ // Simulate store-progress(status="completed") on a terminal task.
260
+ // The store-progress tool's short-circuit returns wasNoOp=true and
261
+ // skips completeTask entirely. Even if we were to call completeTask
262
+ // directly (defense in depth), the row stays unchanged.
263
+ const result = completeTask(task.id, "second output");
264
+ expect(result).toBeNull();
265
+
266
+ const after = getTaskById(task.id);
267
+ expect(after!.output).toBe(snapshot!.output);
268
+ expect(after!.finishedAt).toBe(snapshot!.finishedAt);
269
+ expect(after!.status).toBe(snapshot!.status);
270
+ expect(getLogsByTaskId(task.id).length).toBe(snapshotLogs);
271
+ });
272
+
273
+ test("failing an already-failed task is a no-op at the DB layer", () => {
274
+ const agent = createAgent({
275
+ name: "sp-idempotency-2",
276
+ isLead: false,
277
+ status: "idle",
278
+ capabilities: [],
279
+ });
280
+
281
+ const task = createTaskExtended("SP Task B", { agentId: agent.id });
282
+ startTask(task.id, agent.id);
283
+ failTask(task.id, "first reason");
284
+
285
+ const snapshot = getTaskById(task.id);
286
+ const snapshotLogs = getLogsByTaskId(task.id).length;
287
+
288
+ const result = failTask(task.id, "second reason");
289
+ expect(result).toBeNull();
290
+
291
+ const after = getTaskById(task.id);
292
+ expect(after!.failureReason).toBe(snapshot!.failureReason);
293
+ expect(after!.finishedAt).toBe(snapshot!.finishedAt);
294
+ expect(after!.status).toBe(snapshot!.status);
295
+ expect(getLogsByTaskId(task.id).length).toBe(snapshotLogs);
296
+ });
297
+
298
+ test("completing a task manually marked terminal returns null", () => {
299
+ // Belt-and-suspenders: even if the row was written outside the normal
300
+ // code path (e.g. direct UPDATE), the guard catches it.
301
+ const agent = createAgent({
302
+ name: "sp-idempotency-3",
303
+ isLead: false,
304
+ status: "idle",
305
+ capabilities: [],
306
+ });
307
+
308
+ const task = createTaskExtended("SP Task C", { agentId: agent.id });
309
+ getDb().run(
310
+ "UPDATE agent_tasks SET status = 'completed', output = 'manually written', finishedAt = ? WHERE id = ?",
311
+ [new Date().toISOString(), task.id],
312
+ );
313
+
314
+ const result = completeTask(task.id, "tried to overwrite");
315
+ expect(result).toBeNull();
316
+
317
+ const after = getTaskById(task.id);
318
+ expect(after!.output).toBe("manually written");
319
+ });
320
+ });
@@ -0,0 +1,57 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { closeDb, createAgent, createTask, getAllTasks, getTasksCount, initDb } from "../be/db";
4
+
5
+ const TEST_DB_PATH = "./test-task-search-filter.sqlite";
6
+
7
+ describe("getAllTasks search filter", () => {
8
+ beforeAll(async () => {
9
+ for (const suffix of ["", "-wal", "-shm"]) {
10
+ try {
11
+ await unlink(`${TEST_DB_PATH}${suffix}`);
12
+ } catch {}
13
+ }
14
+ initDb(TEST_DB_PATH);
15
+ });
16
+
17
+ afterAll(async () => {
18
+ closeDb();
19
+ for (const suffix of ["", "-wal", "-shm"]) {
20
+ try {
21
+ await unlink(`${TEST_DB_PATH}${suffix}`);
22
+ } catch {}
23
+ }
24
+ });
25
+
26
+ test("matches by id prefix and substring, plus description", () => {
27
+ const agent = createAgent({
28
+ id: "search-filter-agent",
29
+ name: "Search Filter Agent",
30
+ isLead: false,
31
+ status: "idle",
32
+ });
33
+
34
+ const taskA = createTask(agent.id, "implement partial id search");
35
+ const taskB = createTask(agent.id, "fix navbar styling");
36
+
37
+ // Description-search still works
38
+ const byDescription = getAllTasks({ search: "partial id" });
39
+ expect(byDescription.map((t) => t.id)).toContain(taskA.id);
40
+ expect(byDescription.map((t) => t.id)).not.toContain(taskB.id);
41
+
42
+ // First 8 chars of UUID match the task with that ID
43
+ const idPrefix = taskA.id.slice(0, 8);
44
+ const byPrefix = getAllTasks({ search: idPrefix });
45
+ expect(byPrefix.map((t) => t.id)).toContain(taskA.id);
46
+ expect(byPrefix.map((t) => t.id)).not.toContain(taskB.id);
47
+
48
+ // Arbitrary substring of UUID also matches
49
+ const idMid = taskB.id.slice(9, 17);
50
+ const byMid = getAllTasks({ search: idMid });
51
+ expect(byMid.map((t) => t.id)).toContain(taskB.id);
52
+ expect(byMid.map((t) => t.id)).not.toContain(taskA.id);
53
+
54
+ // Count query honors the same filter
55
+ expect(getTasksCount({ search: idPrefix })).toBe(1);
56
+ });
57
+ });
@@ -64,6 +64,12 @@ export const registerStoreProgressTool = (server: McpServer) => {
64
64
  success: z.boolean(),
65
65
  message: z.string(),
66
66
  task: AgentTaskSchema.optional(),
67
+ wasNoOp: z
68
+ .boolean()
69
+ .optional()
70
+ .describe(
71
+ "True when the call was a no-op because the task was already in a terminal state (completed/failed/cancelled). First-call-wins.",
72
+ ),
67
73
  }),
68
74
  },
69
75
  async ({ taskId, progress, status, output, failureReason, costData }, requestInfo, _meta) => {
@@ -105,6 +111,22 @@ export const registerStoreProgressTool = (server: McpServer) => {
105
111
  let updatedTask = existingTask;
106
112
  const isTerminal = ["completed", "failed", "cancelled"].includes(existingTask.status);
107
113
 
114
+ // Idempotency guard: short-circuit terminal-status writes (completed/failed)
115
+ // BEFORE any side-effects fire (event emission, memory write, follow-up task,
116
+ // business-use ensure). Without this, a multi-session race causes duplicate
117
+ // follow-up tasks to lead, vector index pollution, and spurious BU events.
118
+ // First-call-wins: existing output / finishedAt are preserved.
119
+ if (status && isTerminal) {
120
+ return {
121
+ success: true,
122
+ message:
123
+ `Task "${taskId}" is already ${existingTask.status}; treating as no-op. ` +
124
+ `Existing output preserved (first-call-wins).`,
125
+ task: existingTask,
126
+ wasNoOp: true,
127
+ };
128
+ }
129
+
108
130
  // Update progress if provided (with deduplication)
109
131
  // Skip for tasks already in a terminal state to prevent zombie revival
110
132
  if (progress && !isTerminal) {
@@ -244,8 +266,15 @@ export const registerStoreProgressTool = (server: McpServer) => {
244
266
 
245
267
  const result = txn();
246
268
 
247
- // Index completed and failed tasks as memory (async, non-blocking)
248
- if ((status === "completed" || status === "failed") && result.success && result.task) {
269
+ // Index completed and failed tasks as memory (async, non-blocking).
270
+ // Skip on no-op (idempotent re-call on terminal task) to avoid duplicate
271
+ // memory entries / vector index pollution.
272
+ if (
273
+ (status === "completed" || status === "failed") &&
274
+ result.success &&
275
+ result.task &&
276
+ !("wasNoOp" in result && result.wasNoOp)
277
+ ) {
249
278
  (async () => {
250
279
  try {
251
280
  const taskContent =
@@ -306,7 +335,14 @@ export const registerStoreProgressTool = (server: McpServer) => {
306
335
  // Create follow-up task for the lead when a worker task finishes.
307
336
  // This replaces the old poll-based tasks_finished trigger which was unreliable.
308
337
  // Skip for workflow-managed tasks — the workflow engine handles sequencing via resume.ts.
309
- if (status && result.success && result.task && !result.task.workflowRunId) {
338
+ // Skip on no-op (idempotent re-call on terminal task) to avoid duplicate follow-ups.
339
+ if (
340
+ status &&
341
+ result.success &&
342
+ result.task &&
343
+ !result.task.workflowRunId &&
344
+ !("wasNoOp" in result && result.wasNoOp)
345
+ ) {
310
346
  try {
311
347
  const taskAgent = getAgentById(result.task.agentId ?? "");
312
348
  // Only create follow-ups for worker tasks (not lead's own tasks)