@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.
- package/README.md +3 -3
- package/openapi.json +62 -1
- package/package.json +1 -1
- package/src/agentmail/handlers.ts +87 -6
- package/src/be/db.ts +34 -2
- package/src/be/migrations/042_task_context_key.sql +13 -0
- package/src/commands/runner.ts +1 -0
- package/src/github/handlers.ts +42 -10
- package/src/gitlab/handlers.ts +29 -5
- package/src/hooks/hook.ts +4 -2
- package/src/http/core.ts +36 -26
- package/src/http/mcp-oauth.ts +132 -60
- package/src/http/mcp-servers.ts +5 -1
- package/src/http/schedules.ts +4 -2
- package/src/http/tasks.ts +4 -2
- package/src/linear/sync.ts +22 -10
- package/src/providers/claude-adapter.ts +51 -29
- package/src/scheduler/scheduler.ts +9 -10
- package/src/server.ts +2 -0
- package/src/slack/actions.ts +10 -9
- package/src/slack/assistant.ts +8 -4
- package/src/slack/handlers.ts +8 -3
- package/src/slack/thread-buffer.ts +61 -72
- package/src/tasks/additive-buffer.ts +152 -0
- package/src/tasks/additive-ingress.ts +125 -0
- package/src/tasks/context-key.ts +245 -0
- package/src/tasks/sibling-awareness.ts +144 -0
- package/src/tasks/sibling-block.ts +164 -0
- package/src/tests/additive-buffer.test.ts +186 -0
- package/src/tests/additive-ingress.test.ts +111 -0
- package/src/tests/claude-adapter.test.ts +143 -1
- package/src/tests/context-key-db.test.ts +87 -0
- package/src/tests/context-key.test.ts +173 -0
- package/src/tests/core-auth.test.ts +142 -0
- package/src/tests/mcp-oauth-resolve-secrets.test.ts +79 -0
- package/src/tests/sibling-awareness-db.test.ts +172 -0
- package/src/tests/sibling-block.test.ts +232 -0
- package/src/tests/tool-annotations.test.ts +1 -0
- package/src/tools/slack-post.ts +10 -3
- package/src/tools/slack-start-thread.ts +123 -0
- package/src/tools/tool-config.ts +2 -1
- package/src/tools/update-profile.ts +5 -2
- package/src/types.ts +5 -0
- package/src/workflows/executors/agent-task.ts +21 -14
package/src/slack/actions.ts
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import type { App } from "@slack/bolt";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
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 =
|
|
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);
|
package/src/slack/assistant.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/slack/handlers.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
134
|
-
*
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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 =
|
|
150
|
-
const description = `[Thread follow-up — ${
|
|
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(
|
|
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(
|
|
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(
|
|
173
|
-
const task =
|
|
162
|
+
const mostRecentTask = getMostRecentTaskInThread(channelId, threadTs);
|
|
163
|
+
const task = createTaskWithSiblingAwareness(fullDescription, {
|
|
174
164
|
agentId: lead?.id,
|
|
175
165
|
source: "slack",
|
|
176
|
-
slackChannelId:
|
|
177
|
-
slackThreadTs:
|
|
178
|
-
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:
|
|
183
|
+
messageCount: items.length,
|
|
193
184
|
taskId: task.id,
|
|
194
185
|
hasDependency,
|
|
195
186
|
});
|
|
196
187
|
const fallbackText = hasDependency
|
|
197
|
-
? `${
|
|
198
|
-
: `${
|
|
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:
|
|
203
|
-
thread_ts:
|
|
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,
|
|
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
|
+
}
|