@desplega.ai/agent-swarm 1.69.0 → 1.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +3 -3
  2. package/openapi.json +62 -1
  3. package/package.json +1 -1
  4. package/src/agentmail/handlers.ts +87 -6
  5. package/src/be/db.ts +34 -2
  6. package/src/be/migrations/042_task_context_key.sql +13 -0
  7. package/src/commands/runner.ts +1 -0
  8. package/src/github/handlers.ts +42 -10
  9. package/src/gitlab/handlers.ts +29 -5
  10. package/src/hooks/hook.ts +4 -2
  11. package/src/http/core.ts +36 -26
  12. package/src/http/mcp-oauth.ts +132 -60
  13. package/src/http/mcp-servers.ts +5 -1
  14. package/src/http/schedules.ts +4 -2
  15. package/src/http/tasks.ts +4 -2
  16. package/src/linear/sync.ts +22 -10
  17. package/src/providers/claude-adapter.ts +51 -29
  18. package/src/scheduler/scheduler.ts +9 -10
  19. package/src/server.ts +2 -0
  20. package/src/slack/actions.ts +10 -9
  21. package/src/slack/assistant.ts +8 -4
  22. package/src/slack/handlers.ts +8 -3
  23. package/src/slack/thread-buffer.ts +61 -72
  24. package/src/tasks/additive-buffer.ts +152 -0
  25. package/src/tasks/additive-ingress.ts +125 -0
  26. package/src/tasks/context-key.ts +245 -0
  27. package/src/tasks/sibling-awareness.ts +144 -0
  28. package/src/tasks/sibling-block.ts +164 -0
  29. package/src/tests/additive-buffer.test.ts +186 -0
  30. package/src/tests/additive-ingress.test.ts +111 -0
  31. package/src/tests/claude-adapter.test.ts +143 -1
  32. package/src/tests/context-key-db.test.ts +87 -0
  33. package/src/tests/context-key.test.ts +173 -0
  34. package/src/tests/core-auth.test.ts +142 -0
  35. package/src/tests/mcp-oauth-resolve-secrets.test.ts +79 -0
  36. package/src/tests/sibling-awareness-db.test.ts +172 -0
  37. package/src/tests/sibling-block.test.ts +232 -0
  38. package/src/tests/tool-annotations.test.ts +1 -0
  39. package/src/tools/slack-post.ts +10 -3
  40. package/src/tools/slack-start-thread.ts +123 -0
  41. package/src/tools/tool-config.ts +2 -1
  42. package/src/tools/update-profile.ts +5 -2
  43. package/src/types.ts +5 -0
  44. package/src/workflows/executors/agent-task.ts +21 -14
@@ -1,12 +1,7 @@
1
1
  import type { App } from "@slack/bolt";
2
- import {
3
- cancelTask,
4
- createTaskExtended,
5
- getAgentById,
6
- getLeadAgent,
7
- getTaskById,
8
- resolveUser,
9
- } from "../be/db";
2
+ import { cancelTask, getAgentById, getLeadAgent, getTaskById, resolveUser } from "../be/db";
3
+ import { slackContextKey } from "../tasks/context-key";
4
+ import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
10
5
  import { buildCancelledBlocks, getTaskLink } from "./blocks";
11
6
 
12
7
  export function registerActionHandlers(app: App): void {
@@ -73,7 +68,7 @@ export function registerActionHandlers(app: App): void {
73
68
 
74
69
  const lead = getLeadAgent();
75
70
  const requestedByUserId = resolveUser({ slackUserId: body.user.id })?.id;
76
- const followUpTask = createTaskExtended(followUpText, {
71
+ const followUpTask = createTaskWithSiblingAwareness(followUpText, {
77
72
  agentId: lead?.id,
78
73
  source: "slack",
79
74
  parentTaskId: taskId,
@@ -81,6 +76,12 @@ export function registerActionHandlers(app: App): void {
81
76
  slackThreadTs: originalTask.slackThreadTs,
82
77
  slackUserId: body.user.id,
83
78
  requestedByUserId,
79
+ contextKey: originalTask.slackThreadTs
80
+ ? slackContextKey({
81
+ channelId: originalTask.slackChannelId,
82
+ threadTs: originalTask.slackThreadTs,
83
+ })
84
+ : undefined,
84
85
  });
85
86
 
86
87
  const taskLink = getTaskLink(followUpTask.id);
@@ -1,12 +1,13 @@
1
1
  import { Assistant } from "@slack/bolt";
2
2
  import {
3
- createTaskExtended,
4
3
  getAgentWorkingOnThread,
5
4
  getLeadAgent,
6
5
  getMostRecentTaskInThread,
7
6
  resolveUser,
8
7
  } from "../be/db";
9
8
  import { resolveTemplate } from "../prompts/resolver";
9
+ import { slackContextKey } from "../tasks/context-key";
10
+ import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
10
11
  import { bufferThreadMessage } from "./thread-buffer";
11
12
  // Side-effect import: registers all Slack event templates in the in-memory registry
12
13
  import "./templates";
@@ -82,7 +83,7 @@ export function createAssistant(): Assistant {
82
83
 
83
84
  // Otherwise, create a follow-up task for the working agent
84
85
  const latestTask = getMostRecentTaskInThread(channelId, threadTs);
85
- createTaskExtended(messageText, {
86
+ createTaskWithSiblingAwareness(messageText, {
86
87
  agentId: workingAgent.id,
87
88
  source: "slack",
88
89
  slackChannelId: channelId,
@@ -90,6 +91,7 @@ export function createAssistant(): Assistant {
90
91
  slackUserId: userId,
91
92
  parentTaskId: latestTask?.id,
92
93
  requestedByUserId,
94
+ contextKey: slackContextKey({ channelId, threadTs }),
93
95
  });
94
96
 
95
97
  await safeSetStatus("Processing follow-up...");
@@ -114,25 +116,27 @@ export function createAssistant(): Assistant {
114
116
  const lead = getLeadAgent();
115
117
  if (!lead) {
116
118
  // No lead — still queue the task
117
- createTaskExtended(messageText + channelContext, {
119
+ createTaskWithSiblingAwareness(messageText + channelContext, {
118
120
  source: "slack",
119
121
  slackChannelId: channelId,
120
122
  slackThreadTs: threadTs,
121
123
  slackUserId: userId,
122
124
  requestedByUserId,
125
+ contextKey: slackContextKey({ channelId, threadTs }),
123
126
  });
124
127
  const offlineResult = resolveTemplate("slack.assistant.offline", {});
125
128
  await say(offlineResult.text);
126
129
  return;
127
130
  }
128
131
 
129
- createTaskExtended(messageText + channelContext, {
132
+ createTaskWithSiblingAwareness(messageText + channelContext, {
130
133
  agentId: lead.id,
131
134
  source: "slack",
132
135
  slackChannelId: channelId,
133
136
  slackThreadTs: threadTs,
134
137
  slackUserId: userId,
135
138
  requestedByUserId,
139
+ contextKey: slackContextKey({ channelId, threadTs }),
136
140
  });
137
141
  // setStatus shows typing indicator — watcher will post final result when done
138
142
  } catch (error) {
@@ -10,6 +10,8 @@ import {
10
10
  resolveUser,
11
11
  } from "../be/db";
12
12
  import { resolveTemplate } from "../prompts/resolver";
13
+ import { slackContextKey } from "../tasks/context-key";
14
+ import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
13
15
  import { workflowEventBus } from "../workflows/event-bus";
14
16
  import { buildTreeBlocks, type TreeNode } from "./blocks";
15
17
  import type { SlackFile } from "./files";
@@ -538,13 +540,14 @@ export function registerMessageHandler(app: App): void {
538
540
  }
539
541
 
540
542
  const lead = getLeadAgent();
541
- createTaskExtended(fullTaskDescription, {
543
+ createTaskWithSiblingAwareness(fullTaskDescription, {
542
544
  agentId: lead?.id,
543
545
  source: "slack",
544
546
  slackChannelId: msg.channel,
545
547
  slackThreadTs: threadTs,
546
548
  slackUserId: msg.user,
547
549
  requestedByUserId,
550
+ contextKey: slackContextKey({ channelId: msg.channel, threadTs }),
548
551
  });
549
552
 
550
553
  await say({
@@ -610,7 +613,7 @@ export function registerMessageHandler(app: App): void {
610
613
  try {
611
614
  const latestTask = getMostRecentTaskInThread(msg.channel, threadTs);
612
615
  if (agent.isLead) {
613
- const task = createTaskExtended(fullTaskDescription, {
616
+ const task = createTaskWithSiblingAwareness(fullTaskDescription, {
614
617
  agentId: agent.id,
615
618
  source: "slack",
616
619
  slackChannelId: msg.channel,
@@ -618,19 +621,21 @@ export function registerMessageHandler(app: App): void {
618
621
  slackUserId: msg.user,
619
622
  parentTaskId: latestTask?.id,
620
623
  requestedByUserId,
624
+ contextKey: slackContextKey({ channelId: msg.channel, threadTs }),
621
625
  });
622
626
  results.assigned.push({ agentName: agent.name, taskId: task.id });
623
627
  continue;
624
628
  }
625
629
 
626
630
  // Workers receive tasks as before
627
- const task = createTaskExtended(fullTaskDescription, {
631
+ const task = createTaskWithSiblingAwareness(fullTaskDescription, {
628
632
  agentId: agent.id,
629
633
  source: "slack",
630
634
  slackChannelId: msg.channel,
631
635
  slackThreadTs: threadTs,
632
636
  slackUserId: msg.user,
633
637
  requestedByUserId,
638
+ contextKey: slackContextKey({ channelId: msg.channel, threadTs }),
634
639
  });
635
640
 
636
641
  // Check if agent has an in-progress task in this thread (queued follow-up)
@@ -1,9 +1,7 @@
1
- import {
2
- createTaskExtended,
3
- getLatestActiveTaskInThread,
4
- getLeadAgent,
5
- getMostRecentTaskInThread,
6
- } from "../be/db";
1
+ import { getLatestActiveTaskInThread, getLeadAgent, getMostRecentTaskInThread } from "../be/db";
2
+ import { createAdditiveBuffer } from "../tasks/additive-buffer";
3
+ import { slackContextKey } from "../tasks/context-key";
4
+ import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
7
5
  import { getSlackApp } from "./app";
8
6
  import { buildBufferFlushBlocks } from "./blocks";
9
7
  import { registerTreeMessage } from "./watcher";
@@ -12,24 +10,30 @@ interface BufferedMessage {
12
10
  text: string;
13
11
  userId: string;
14
12
  ts: string;
15
- }
16
-
17
- interface BufferedThread {
18
13
  channelId: string;
19
14
  threadTs: string;
20
- messages: BufferedMessage[];
21
- timer: Timer;
22
- slackUserId: string; // original requester (first message sender)
23
15
  }
24
16
 
25
- const threadBuffers = new Map<string, BufferedThread>();
26
-
27
17
  const BUFFER_TIMEOUT_MS = Number(process.env.ADDITIVE_SLACK_BUFFER_MS) || 10_000;
28
18
 
29
19
  function makeKey(channelId: string, threadTs: string): string {
30
20
  return `${channelId}:${threadTs}`;
31
21
  }
32
22
 
23
+ function splitKey(key: string): { channelId: string; threadTs: string } | null {
24
+ const idx = key.indexOf(":");
25
+ if (idx === -1) return null;
26
+ return { channelId: key.slice(0, idx), threadTs: key.slice(idx + 1) };
27
+ }
28
+
29
+ const slackBuffer = createAdditiveBuffer<BufferedMessage>({
30
+ timeoutMs: BUFFER_TIMEOUT_MS,
31
+ label: "slack-thread",
32
+ onFlush: async (items, key, reason) => {
33
+ await slackFlush(items, key, reason === "manual");
34
+ },
35
+ });
36
+
33
37
  /**
34
38
  * Add a message to the thread buffer. Resets the debounce timer.
35
39
  */
@@ -40,43 +44,27 @@ export function bufferThreadMessage(
40
44
  userId: string,
41
45
  ts: string,
42
46
  ): void {
43
- const key = makeKey(channelId, threadTs);
44
- const existing = threadBuffers.get(key);
45
-
46
- if (existing) {
47
- // Append to existing buffer, reset timer
48
- existing.messages.push({ text, userId, ts });
49
- clearTimeout(existing.timer);
50
- existing.timer = setTimeout(() => flushBuffer(key, false), BUFFER_TIMEOUT_MS);
51
- console.log(
52
- `[Slack] Buffer append: ${key} (${existing.messages.length} messages, timer reset to ${BUFFER_TIMEOUT_MS}ms)`,
53
- );
54
- } else {
55
- // Create new buffer entry
56
- const timer = setTimeout(() => flushBuffer(key, false), BUFFER_TIMEOUT_MS);
57
- threadBuffers.set(key, {
58
- channelId,
59
- threadTs,
60
- messages: [{ text, userId, ts }],
61
- timer,
62
- slackUserId: userId,
63
- });
64
- console.log(`[Slack] Buffer created: ${key} (timer set to ${BUFFER_TIMEOUT_MS}ms)`);
65
- }
47
+ slackBuffer.enqueue(makeKey(channelId, threadTs), {
48
+ text,
49
+ userId,
50
+ ts,
51
+ channelId,
52
+ threadTs,
53
+ });
66
54
  }
67
55
 
68
56
  /**
69
57
  * Check if a thread currently has a pending buffer.
70
58
  */
71
59
  export function isThreadBuffered(channelId: string, threadTs: string): boolean {
72
- return threadBuffers.has(makeKey(channelId, threadTs));
60
+ return slackBuffer.isBuffered(makeKey(channelId, threadTs));
73
61
  }
74
62
 
75
63
  /**
76
64
  * Get the number of messages currently in the buffer for a thread key.
77
65
  */
78
66
  export function getBufferMessageCount(key: string): number {
79
- return threadBuffers.get(key)?.messages.length ?? 0;
67
+ return slackBuffer.count(key);
80
68
  }
81
69
 
82
70
  /**
@@ -84,12 +72,7 @@ export function getBufferMessageCount(key: string): number {
84
72
  * and flushes with immediate=true (no dependsOn).
85
73
  */
86
74
  export async function instantFlush(key: string): Promise<void> {
87
- const buffer = threadBuffers.get(key);
88
- if (buffer) {
89
- clearTimeout(buffer.timer);
90
- console.log(`[Slack] Instant flush triggered: ${key}`);
91
- }
92
- await flushBuffer(key, true);
75
+ await slackBuffer.instantFlush(key);
93
76
  }
94
77
 
95
78
  /**
@@ -130,27 +113,34 @@ async function getThreadContextForBuffer(channelId: string, threadTs: string): P
130
113
  }
131
114
 
132
115
  /**
133
- * Flush the buffer: concatenate messages, create task with optional dependency chaining.
134
- * @param key - The buffer key (channelId:threadTs)
135
- * @param immediate - If true, skip dependency chaining (used by !now)
116
+ * Flush the Slack thread buffer: concatenate messages, create task with optional
117
+ * dependency chaining.
136
118
  */
137
- async function flushBuffer(key: string, immediate = false): Promise<void> {
138
- const buffer = threadBuffers.get(key);
139
- if (!buffer || buffer.messages.length === 0) {
140
- threadBuffers.delete(key);
119
+ async function slackFlush(
120
+ items: BufferedMessage[],
121
+ key: string,
122
+ immediate: boolean,
123
+ ): Promise<void> {
124
+ if (items.length === 0) return;
125
+
126
+ const split = splitKey(key);
127
+ if (!split) {
128
+ console.warn(`[Slack] Buffer flush: malformed key ${key}`);
141
129
  return;
142
130
  }
131
+ const { channelId, threadTs } = split;
132
+ // Buffer is guaranteed to have at least one item — the first carries the
133
+ // original requester's userId (same semantics as the pre-refactor version).
134
+ const originalRequesterId = items[0]!.userId;
143
135
 
144
- console.log(
145
- `[Slack] Flushing buffer: ${key} (${buffer.messages.length} messages, immediate=${immediate})`,
146
- );
136
+ console.log(`[Slack] Flushing buffer: ${key} (${items.length} messages, immediate=${immediate})`);
147
137
 
148
138
  // Build combined task description
149
- const combinedText = buffer.messages.map((m) => m.text).join("\n---\n");
150
- const description = `[Thread follow-up — ${buffer.messages.length} message(s) buffered]\n\n${combinedText}`;
139
+ const combinedText = items.map((m) => m.text).join("\n---\n");
140
+ const description = `[Thread follow-up — ${items.length} message(s) buffered]\n\n${combinedText}`;
151
141
 
152
142
  // Find the latest active task in this thread for dependency chaining
153
- const latestActiveTask = getLatestActiveTaskInThread(buffer.channelId, buffer.threadTs);
143
+ const latestActiveTask = getLatestActiveTaskInThread(channelId, threadTs);
154
144
  if (latestActiveTask) {
155
145
  console.log(
156
146
  `[Slack] Dependency chaining: latest active task ${latestActiveTask.id} (status: ${latestActiveTask.status})`,
@@ -160,7 +150,7 @@ async function flushBuffer(key: string, immediate = false): Promise<void> {
160
150
  const lead = getLeadAgent();
161
151
 
162
152
  // Thread context for the task
163
- const threadContext = await getThreadContextForBuffer(buffer.channelId, buffer.threadTs);
153
+ const threadContext = await getThreadContextForBuffer(channelId, threadTs);
164
154
  const fullDescription = threadContext
165
155
  ? `<thread_context>\n${threadContext}\n</thread_context>\n\n${description}`
166
156
  : description;
@@ -169,15 +159,16 @@ async function flushBuffer(key: string, immediate = false): Promise<void> {
169
159
  // Otherwise, depend on the latest active task so it queues naturally.
170
160
  const dependsOn = !immediate && latestActiveTask ? [latestActiveTask.id] : undefined;
171
161
 
172
- const mostRecentTask = getMostRecentTaskInThread(buffer.channelId, buffer.threadTs);
173
- const task = createTaskExtended(fullDescription, {
162
+ const mostRecentTask = getMostRecentTaskInThread(channelId, threadTs);
163
+ const task = createTaskWithSiblingAwareness(fullDescription, {
174
164
  agentId: lead?.id,
175
165
  source: "slack",
176
- slackChannelId: buffer.channelId,
177
- slackThreadTs: buffer.threadTs,
178
- slackUserId: buffer.slackUserId,
166
+ slackChannelId: channelId,
167
+ slackThreadTs: threadTs,
168
+ slackUserId: originalRequesterId,
179
169
  dependsOn,
180
170
  parentTaskId: mostRecentTask?.id,
171
+ contextKey: slackContextKey({ channelId, threadTs }),
181
172
  });
182
173
 
183
174
  console.log(
@@ -189,18 +180,18 @@ async function flushBuffer(key: string, immediate = false): Promise<void> {
189
180
  if (app) {
190
181
  const hasDependency = !immediate && !!latestActiveTask;
191
182
  const blocks = buildBufferFlushBlocks({
192
- messageCount: buffer.messages.length,
183
+ messageCount: items.length,
193
184
  taskId: task.id,
194
185
  hasDependency,
195
186
  });
196
187
  const fallbackText = hasDependency
197
- ? `${buffer.messages.length} follow-up message(s) queued pending completion of current task`
198
- : `${buffer.messages.length} follow-up message(s) batched into task`;
188
+ ? `${items.length} follow-up message(s) queued pending completion of current task`
189
+ : `${items.length} follow-up message(s) batched into task`;
199
190
 
200
191
  try {
201
192
  const result = await app.client.chat.postMessage({
202
- channel: buffer.channelId,
203
- thread_ts: buffer.threadTs,
193
+ channel: channelId,
194
+ thread_ts: threadTs,
204
195
  text: fallbackText,
205
196
  // biome-ignore lint/suspicious/noExplicitAny: Block Kit objects
206
197
  blocks: blocks as any,
@@ -208,7 +199,7 @@ async function flushBuffer(key: string, immediate = false): Promise<void> {
208
199
 
209
200
  // Register the batching message as the tree message for this task
210
201
  if (result.ts && task) {
211
- registerTreeMessage(task.id, buffer.channelId, buffer.threadTs, result.ts);
202
+ registerTreeMessage(task.id, channelId, threadTs, result.ts);
212
203
  console.log(
213
204
  `[Slack] Registered batched task ${task.id.slice(0, 8)} tree message from buffer flush`,
214
205
  );
@@ -217,6 +208,4 @@ async function flushBuffer(key: string, immediate = false): Promise<void> {
217
208
  console.error("[Slack] Failed to post buffer flush feedback:", error);
218
209
  }
219
210
  }
220
-
221
- threadBuffers.delete(key);
222
211
  }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Generic additive/debounce buffer, keyed on `contextKey` (or any string key).
3
+ *
4
+ * Phase 2 of cross-ingress sibling-task awareness — research §5.
5
+ *
6
+ * Extracted from `src/slack/thread-buffer.ts` so any ingress (AgentMail,
7
+ * GitHub/GitLab issue comments, Linear comments — NOT schedule/workflow) can
8
+ * coalesce rapid follow-up inputs into a single task.
9
+ *
10
+ * The primitive is a factory: each caller owns its own buffer registry with
11
+ * its own flush callback. That keeps flush semantics ingress-specific while
12
+ * the debounce / append / count plumbing is shared.
13
+ *
14
+ * Behavior:
15
+ * - `enqueue(key, item)` appends `item` to the in-memory buffer for `key`.
16
+ * - The debounce timer is reset on every append.
17
+ * - When the timer fires, `onFlush(items, key, reason="timer")` is called
18
+ * with the accumulated list.
19
+ * - `instantFlush(key)` fires the callback immediately with
20
+ * `reason="manual"` and clears the buffer.
21
+ * - `cancel(key)` drops the buffer without flushing (used by ingress when
22
+ * the underlying context becomes irrelevant — e.g. user cancels).
23
+ *
24
+ * Concurrency: single-process, single-event-loop. No cross-instance locking.
25
+ * Flush callbacks run sequentially per key (the buffer is cleared BEFORE the
26
+ * callback fires, so re-enqueues during `onFlush` create a fresh buffer).
27
+ */
28
+
29
+ export type BufferFlushReason = "timer" | "manual";
30
+
31
+ export interface AdditiveBufferOptions<T> {
32
+ /**
33
+ * Debounce timeout. Resets on every append. When it elapses without new
34
+ * appends, `onFlush` is called.
35
+ */
36
+ timeoutMs: number;
37
+ /**
38
+ * Called with the accumulated items when the buffer flushes. Receives the
39
+ * `contextKey` the buffer was created under and a `reason` indicating
40
+ * whether this was a timer-driven flush or a manual (`instantFlush`) one.
41
+ *
42
+ * Errors thrown here are caught and logged — they do NOT re-enter the
43
+ * buffer, because the buffer has already been cleared by the time `onFlush`
44
+ * is called. Callers that need retry semantics must implement them inside
45
+ * `onFlush`.
46
+ */
47
+ onFlush: (items: T[], contextKey: string, reason: BufferFlushReason) => void | Promise<void>;
48
+ /**
49
+ * Optional label, used in log lines (`[buffer:${label}]`). Helps when
50
+ * multiple buffers exist in the same process.
51
+ */
52
+ label?: string;
53
+ }
54
+
55
+ export interface AdditiveBuffer<T> {
56
+ /** Append an item, creating the buffer if needed. Resets the debounce timer. */
57
+ enqueue(contextKey: string, item: T): void;
58
+ /** True when a buffer exists for this key (i.e. at least one item is queued). */
59
+ isBuffered(contextKey: string): boolean;
60
+ /** Number of items currently queued for this key, or 0. */
61
+ count(contextKey: string): number;
62
+ /** Flush immediately with `reason="manual"`. No-op when no buffer exists. */
63
+ instantFlush(contextKey: string): Promise<void>;
64
+ /** Drop the buffer without flushing. No-op when no buffer exists. */
65
+ cancel(contextKey: string): boolean;
66
+ /** For tests / diagnostics. */
67
+ keys(): string[];
68
+ }
69
+
70
+ interface BufferEntry<T> {
71
+ items: T[];
72
+ timer: ReturnType<typeof setTimeout>;
73
+ }
74
+
75
+ export function createAdditiveBuffer<T>(options: AdditiveBufferOptions<T>): AdditiveBuffer<T> {
76
+ const { timeoutMs, onFlush, label } = options;
77
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
78
+ throw new Error(`additive-buffer: timeoutMs must be a positive number, got ${timeoutMs}`);
79
+ }
80
+
81
+ const buffers = new Map<string, BufferEntry<T>>();
82
+ const prefix = label ? `[buffer:${label}]` : "[buffer]";
83
+
84
+ function scheduleFlush(key: string) {
85
+ return setTimeout(() => {
86
+ void doFlush(key, "timer");
87
+ }, timeoutMs);
88
+ }
89
+
90
+ async function doFlush(key: string, reason: BufferFlushReason): Promise<void> {
91
+ const entry = buffers.get(key);
92
+ if (!entry || entry.items.length === 0) {
93
+ buffers.delete(key);
94
+ return;
95
+ }
96
+ clearTimeout(entry.timer);
97
+ buffers.delete(key);
98
+ try {
99
+ await onFlush(entry.items, key, reason);
100
+ } catch (error) {
101
+ console.error(`${prefix} onFlush threw for key=${key}:`, error);
102
+ }
103
+ }
104
+
105
+ return {
106
+ enqueue(contextKey: string, item: T): void {
107
+ if (!contextKey) {
108
+ throw new Error("additive-buffer: contextKey is required");
109
+ }
110
+ const existing = buffers.get(contextKey);
111
+ if (existing) {
112
+ clearTimeout(existing.timer);
113
+ existing.items.push(item);
114
+ existing.timer = scheduleFlush(contextKey);
115
+ console.log(
116
+ `${prefix} append: ${contextKey} (${existing.items.length} items, timer reset to ${timeoutMs}ms)`,
117
+ );
118
+ } else {
119
+ const entry: BufferEntry<T> = {
120
+ items: [item],
121
+ timer: scheduleFlush(contextKey),
122
+ };
123
+ buffers.set(contextKey, entry);
124
+ console.log(`${prefix} created: ${contextKey} (timer set to ${timeoutMs}ms)`);
125
+ }
126
+ },
127
+
128
+ isBuffered(contextKey: string): boolean {
129
+ return buffers.has(contextKey);
130
+ },
131
+
132
+ count(contextKey: string): number {
133
+ return buffers.get(contextKey)?.items.length ?? 0;
134
+ },
135
+
136
+ instantFlush(contextKey: string): Promise<void> {
137
+ return doFlush(contextKey, "manual");
138
+ },
139
+
140
+ cancel(contextKey: string): boolean {
141
+ const entry = buffers.get(contextKey);
142
+ if (!entry) return false;
143
+ clearTimeout(entry.timer);
144
+ buffers.delete(contextKey);
145
+ return true;
146
+ },
147
+
148
+ keys(): string[] {
149
+ return Array.from(buffers.keys());
150
+ },
151
+ };
152
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Generic "additive ingress" helper for Phase 2 cross-ingress sibling-awareness.
3
+ *
4
+ * Wraps `createAdditiveBuffer` with the typical pattern used by comment-style
5
+ * ingress points (AgentMail threads, GitHub/GitLab issue comments, Linear
6
+ * comments): when a sibling task is already in flight for the same contextKey,
7
+ * debounce rapid follow-up inputs into a SINGLE follow-up task instead of
8
+ * spawning N tasks.
9
+ *
10
+ * This generalizes the Slack `ADDITIVE_SLACK` buffer — Slack's own buffer stays
11
+ * as-is to preserve exact behaviour; other ingress points opt in via env flags.
12
+ *
13
+ * Default: opt-in (env flag unset => all calls are no-ops; caller proceeds to
14
+ * create a task normally).
15
+ */
16
+
17
+ import { type AdditiveBuffer, createAdditiveBuffer } from "./additive-buffer";
18
+
19
+ export type IngressFlushReason = "timer" | "manual";
20
+
21
+ export interface IngressBufferItem<T> {
22
+ payload: T;
23
+ enqueuedAt: number;
24
+ }
25
+
26
+ export interface IngressBufferOptions<T> {
27
+ /** Short identifier used in logs, e.g. "agentmail", "github-issue-comment". */
28
+ source: string;
29
+ /**
30
+ * Env flag name (e.g. `"ADDITIVE_AGENTMAIL"`). When the flag resolves to
31
+ * `"true"` the buffer is enabled; otherwise `maybeBuffer()` is a no-op.
32
+ */
33
+ envFlag: string;
34
+ /** Debounce timeout in ms. Reset on every enqueue. Default: 10000ms. */
35
+ timeoutMs?: number;
36
+ /**
37
+ * Called when the buffer flushes (timer expiry OR manual). Receives the
38
+ * payloads in arrival order, the contextKey, and the reason. Errors thrown
39
+ * here are logged and swallowed.
40
+ */
41
+ onFlush: (items: T[], contextKey: string, reason: IngressFlushReason) => Promise<void> | void;
42
+ }
43
+
44
+ export interface IngressBuffer<T> {
45
+ /**
46
+ * `true` when the env flag was set to `"true"` at construction time.
47
+ * Callers should still guard with `maybeBuffer` — this is informational.
48
+ */
49
+ enabled: boolean;
50
+ /**
51
+ * Attempt to buffer an input. Returns `true` when the item was buffered
52
+ * (caller MUST NOT create a task), `false` otherwise (caller proceeds).
53
+ *
54
+ * Params:
55
+ * - `contextKey` — uniform sibling key from Phase 1. Empty string disables.
56
+ * - `siblingInFlight` — whether the caller already knows a sibling exists
57
+ * for `contextKey`. Callers typically pass the boolean from
58
+ * `getInProgressTasksByContextKey(contextKey).length > 0`.
59
+ * - `payload` — the item to buffer.
60
+ */
61
+ maybeBuffer(contextKey: string, siblingInFlight: boolean, payload: T): boolean;
62
+ /** True if the key currently has a pending buffer (for debugging/tests). */
63
+ isBuffered(contextKey: string): boolean;
64
+ /** Count of items in the buffer for `contextKey`. */
65
+ count(contextKey: string): number;
66
+ /** Flush immediately, cancelling the debounce timer. */
67
+ instantFlush(contextKey: string): Promise<void>;
68
+ /** Drop buffered items without flushing. */
69
+ cancel(contextKey: string): void;
70
+ /** Raw underlying buffer — escape hatch for tests. */
71
+ _buffer: AdditiveBuffer<T>;
72
+ }
73
+
74
+ /**
75
+ * Read the env flag value lazily so tests can toggle it without re-importing.
76
+ */
77
+ function envEnabled(flag: string): boolean {
78
+ return process.env[flag] === "true";
79
+ }
80
+
81
+ /**
82
+ * Create an ingress buffer. The wrapper records the env flag at construction
83
+ * time (not per-call) so behaviour is stable across a process run.
84
+ *
85
+ * Consumers typically:
86
+ * 1. Look up siblings for the contextKey (`getInProgressTasksByContextKey`).
87
+ * 2. Call `buffer.maybeBuffer(contextKey, siblings.length > 0, payload)`.
88
+ * 3. If `true`, stop — buffered. Otherwise, proceed to `createTaskWithSiblingAwareness`.
89
+ */
90
+ export function createIngressBuffer<T>(opts: IngressBufferOptions<T>): IngressBuffer<T> {
91
+ const enabled = envEnabled(opts.envFlag);
92
+ const timeoutMs = opts.timeoutMs ?? 10_000;
93
+
94
+ const buffer = createAdditiveBuffer<T>({
95
+ timeoutMs,
96
+ label: opts.source,
97
+ onFlush: async (items, key, reason) => {
98
+ await opts.onFlush(items, key, reason);
99
+ },
100
+ });
101
+
102
+ return {
103
+ enabled,
104
+ maybeBuffer(contextKey, siblingInFlight, payload) {
105
+ if (!enabled) return false;
106
+ if (!contextKey) return false;
107
+ if (!siblingInFlight) return false;
108
+ buffer.enqueue(contextKey, payload);
109
+ return true;
110
+ },
111
+ isBuffered(contextKey) {
112
+ return buffer.isBuffered(contextKey);
113
+ },
114
+ count(contextKey) {
115
+ return buffer.count(contextKey);
116
+ },
117
+ instantFlush(contextKey) {
118
+ return buffer.instantFlush(contextKey);
119
+ },
120
+ cancel(contextKey) {
121
+ buffer.cancel(contextKey);
122
+ },
123
+ _buffer: buffer,
124
+ };
125
+ }