@desplega.ai/agent-swarm 1.100.2 → 1.100.3
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 +1 -1
- package/package.json +1 -1
- package/src/be/db.ts +131 -4
- package/src/be/memory/raters/retrieval.ts +6 -3
- package/src/be/migrations/097_memory_retrieval_grouping.sql +10 -0
- package/src/github/handlers.ts +84 -7
- package/src/github/templates.ts +6 -2
- package/src/heartbeat/heartbeat.ts +191 -5
- package/src/providers/claude-adapter.ts +41 -4
- package/src/slack/assistant.ts +28 -0
- package/src/slack/channel-join.ts +38 -3
- package/src/slack/handlers.ts +4 -1
- package/src/tasks/worker-follow-up.ts +181 -20
- package/src/tests/claude-adapter-binary.test.ts +74 -0
- package/src/tests/github-handlers-inline-comments.test.ts +308 -0
- package/src/tests/heartbeat-reroute-decision.test.ts +570 -0
- package/src/tests/heartbeat-supersede-resume.test.ts +137 -0
- package/src/tests/heartbeat.test.ts +4 -2
- package/src/tests/memory-rater-implicit-citation.test.ts +31 -0
- package/src/tests/prompt-template-remaining.test.ts +2 -1
- package/src/tests/slack-assistant-comention-production.test.ts +319 -0
- package/src/tests/slack-assistant-comention.test.ts +139 -0
- package/src/tests/slack-channel-join.test.ts +150 -16
- package/src/tools/send-task.ts +51 -1
- package/src/tools/templates.ts +61 -0
|
@@ -10,21 +10,70 @@ function makePlatformError(code: string): Error {
|
|
|
10
10
|
return err;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
type ChannelShape = {
|
|
14
|
+
is_ext_shared?: boolean;
|
|
15
|
+
is_pending_ext_shared?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function makeClient(opts: {
|
|
19
|
+
channel?: ChannelShape;
|
|
20
|
+
infoResult?: () => unknown;
|
|
21
|
+
joinResult?: () => unknown;
|
|
22
|
+
}): {
|
|
23
|
+
client: WebClient;
|
|
24
|
+
infoFn: ReturnType<typeof mock>;
|
|
25
|
+
joinFn: ReturnType<typeof mock>;
|
|
26
|
+
} {
|
|
27
|
+
const infoFn = mock(
|
|
28
|
+
opts.infoResult
|
|
29
|
+
? opts.infoResult
|
|
30
|
+
: () => Promise.resolve({ channel: opts.channel ?? { is_ext_shared: false } }),
|
|
31
|
+
);
|
|
32
|
+
const joinFn = mock(opts.joinResult ? opts.joinResult : () => Promise.resolve({}));
|
|
33
|
+
const client = {
|
|
34
|
+
conversations: { info: infoFn, join: joinFn },
|
|
35
|
+
} as unknown as WebClient;
|
|
36
|
+
return { client, infoFn, joinFn };
|
|
37
|
+
}
|
|
38
|
+
|
|
13
39
|
describe("withAutoJoin", () => {
|
|
14
|
-
test("success: fn called once, join not called", async () => {
|
|
15
|
-
const joinFn =
|
|
16
|
-
const client = { conversations: { join: joinFn } } as unknown as WebClient;
|
|
40
|
+
test("success: fn called once, join and info not called", async () => {
|
|
41
|
+
const { client, infoFn, joinFn } = makeClient({});
|
|
17
42
|
const fn = mock(() => Promise.resolve("ok"));
|
|
18
43
|
|
|
19
44
|
const result = await withAutoJoin(client, "C123", fn);
|
|
20
45
|
expect(result).toBe("ok");
|
|
21
46
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
47
|
+
expect(infoFn).not.toHaveBeenCalled();
|
|
22
48
|
expect(joinFn).not.toHaveBeenCalled();
|
|
23
49
|
});
|
|
24
50
|
|
|
25
|
-
test("not_in_channel: calls join
|
|
26
|
-
const joinFn =
|
|
27
|
-
|
|
51
|
+
test("not_in_channel on internal channel: fetches info, calls join, retries fn exactly once", async () => {
|
|
52
|
+
const { client, infoFn, joinFn } = makeClient({
|
|
53
|
+
channel: { is_ext_shared: false },
|
|
54
|
+
});
|
|
55
|
+
let callCount = 0;
|
|
56
|
+
const fn = mock(async () => {
|
|
57
|
+
callCount++;
|
|
58
|
+
if (callCount === 1) throw makePlatformError("not_in_channel");
|
|
59
|
+
return "retried-ok";
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const result = await withAutoJoin(client, "CPUB", fn);
|
|
63
|
+
expect(result).toBe("retried-ok");
|
|
64
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
65
|
+
expect(infoFn).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(infoFn).toHaveBeenCalledWith({ channel: "CPUB" });
|
|
67
|
+
expect(joinFn).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(joinFn).toHaveBeenCalledWith({ channel: "CPUB" });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("info failure falls back to join and retries fn", async () => {
|
|
72
|
+
const { client, infoFn, joinFn } = makeClient({
|
|
73
|
+
infoResult: () => {
|
|
74
|
+
throw makePlatformError("channel_not_found");
|
|
75
|
+
},
|
|
76
|
+
});
|
|
28
77
|
let callCount = 0;
|
|
29
78
|
const fn = mock(async () => {
|
|
30
79
|
callCount++;
|
|
@@ -35,46 +84,131 @@ describe("withAutoJoin", () => {
|
|
|
35
84
|
const result = await withAutoJoin(client, "CPUB", fn);
|
|
36
85
|
expect(result).toBe("retried-ok");
|
|
37
86
|
expect(fn).toHaveBeenCalledTimes(2);
|
|
87
|
+
expect(infoFn).toHaveBeenCalledTimes(1);
|
|
38
88
|
expect(joinFn).toHaveBeenCalledTimes(1);
|
|
39
89
|
expect(joinFn).toHaveBeenCalledWith({ channel: "CPUB" });
|
|
40
90
|
});
|
|
41
91
|
|
|
42
|
-
test("private channel: join fails with
|
|
43
|
-
const joinFn =
|
|
44
|
-
|
|
92
|
+
test("private channel: info returns internal, join fails with method_not_supported → descriptive error", async () => {
|
|
93
|
+
const { client, infoFn, joinFn } = makeClient({
|
|
94
|
+
channel: { is_ext_shared: false },
|
|
95
|
+
joinResult: () => {
|
|
96
|
+
throw makePlatformError("method_not_supported_for_channel_type");
|
|
97
|
+
},
|
|
45
98
|
});
|
|
46
|
-
const client = { conversations: { join: joinFn } } as unknown as WebClient;
|
|
47
99
|
const fn = mock(() => {
|
|
48
100
|
throw makePlatformError("not_in_channel");
|
|
49
101
|
});
|
|
50
102
|
|
|
51
103
|
await expect(withAutoJoin(client, "CPRIV", fn)).rejects.toThrow("invite the bot");
|
|
104
|
+
expect(infoFn).toHaveBeenCalledTimes(1);
|
|
52
105
|
expect(joinFn).toHaveBeenCalledTimes(1);
|
|
53
106
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
54
107
|
});
|
|
55
108
|
|
|
56
|
-
test("
|
|
57
|
-
const joinFn =
|
|
58
|
-
|
|
109
|
+
test("info failure preserves private-channel invite error from join", async () => {
|
|
110
|
+
const { client, infoFn, joinFn } = makeClient({
|
|
111
|
+
infoResult: () => {
|
|
112
|
+
throw makePlatformError("not_in_channel");
|
|
113
|
+
},
|
|
114
|
+
joinResult: () => {
|
|
115
|
+
throw makePlatformError("method_not_supported_for_channel_type");
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
const fn = mock(() => {
|
|
119
|
+
throw makePlatformError("not_in_channel");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await expect(withAutoJoin(client, "CPRIV", fn)).rejects.toThrow("invite the bot");
|
|
123
|
+
expect(infoFn).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect(joinFn).toHaveBeenCalledTimes(1);
|
|
125
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("non-not_in_channel error: rethrown without info or join", async () => {
|
|
129
|
+
const { client, infoFn, joinFn } = makeClient({});
|
|
59
130
|
const fn = mock(() => {
|
|
60
131
|
throw makePlatformError("channel_not_found");
|
|
61
132
|
});
|
|
62
133
|
|
|
63
134
|
await expect(withAutoJoin(client, "C123", fn)).rejects.toThrow("channel_not_found");
|
|
135
|
+
expect(infoFn).not.toHaveBeenCalled();
|
|
64
136
|
expect(joinFn).not.toHaveBeenCalled();
|
|
65
137
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
66
138
|
});
|
|
67
139
|
|
|
68
140
|
test("retry is bounded: second fn error propagates without another join", async () => {
|
|
69
|
-
const joinFn =
|
|
70
|
-
|
|
71
|
-
|
|
141
|
+
const { client, infoFn, joinFn } = makeClient({
|
|
142
|
+
channel: { is_ext_shared: false },
|
|
143
|
+
});
|
|
72
144
|
const fn = mock(() => {
|
|
73
145
|
throw makePlatformError("not_in_channel");
|
|
74
146
|
});
|
|
75
147
|
|
|
76
148
|
await expect(withAutoJoin(client, "C123", fn)).rejects.toThrow("not_in_channel");
|
|
77
149
|
expect(fn).toHaveBeenCalledTimes(2);
|
|
150
|
+
expect(infoFn).toHaveBeenCalledTimes(1);
|
|
78
151
|
expect(joinFn).toHaveBeenCalledTimes(1); // no infinite loop
|
|
79
152
|
});
|
|
153
|
+
|
|
154
|
+
// --- External channel guard tests ---
|
|
155
|
+
|
|
156
|
+
test("external guard: is_ext_shared=true → throws invite error, join not called", async () => {
|
|
157
|
+
const { client, joinFn } = makeClient({
|
|
158
|
+
channel: { is_ext_shared: true },
|
|
159
|
+
});
|
|
160
|
+
const fn = mock(() => {
|
|
161
|
+
throw makePlatformError("not_in_channel");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await expect(withAutoJoin(client, "CEXT", fn)).rejects.toThrow("invite the bot");
|
|
165
|
+
expect(joinFn).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("external guard: is_pending_ext_shared=true → throws invite error, join not called", async () => {
|
|
169
|
+
const { client, joinFn } = makeClient({
|
|
170
|
+
channel: { is_ext_shared: false, is_pending_ext_shared: true },
|
|
171
|
+
});
|
|
172
|
+
const fn = mock(() => {
|
|
173
|
+
throw makePlatformError("not_in_channel");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await expect(withAutoJoin(client, "CPENDING", fn)).rejects.toThrow("invite the bot");
|
|
177
|
+
expect(joinFn).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("external guard: internal public channel (is_ext_shared:false) → join proceeds", async () => {
|
|
181
|
+
const { client, joinFn } = makeClient({
|
|
182
|
+
channel: { is_ext_shared: false },
|
|
183
|
+
});
|
|
184
|
+
let callCount = 0;
|
|
185
|
+
const fn = mock(async () => {
|
|
186
|
+
callCount++;
|
|
187
|
+
if (callCount === 1) throw makePlatformError("not_in_channel");
|
|
188
|
+
return "joined-ok";
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const result = await withAutoJoin(client, "CPUB", fn);
|
|
192
|
+
expect(result).toBe("joined-ok");
|
|
193
|
+
expect(joinFn).toHaveBeenCalledTimes(1);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("external guard: Enterprise Grid org-shared channel (is_ext_shared:false, multiple teams) → join proceeds, no false-positive", async () => {
|
|
197
|
+
// An internal org-shared channel on Enterprise Grid legitimately lists multiple
|
|
198
|
+
// internal team IDs. The guard must rely solely on is_ext_shared/is_pending_ext_shared
|
|
199
|
+
// — not team-ID comparison — to avoid false-positives here.
|
|
200
|
+
const { client, joinFn } = makeClient({
|
|
201
|
+
channel: { is_ext_shared: false },
|
|
202
|
+
});
|
|
203
|
+
let callCount = 0;
|
|
204
|
+
const fn = mock(async () => {
|
|
205
|
+
callCount++;
|
|
206
|
+
if (callCount === 1) throw makePlatformError("not_in_channel");
|
|
207
|
+
return "joined-ok";
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const result = await withAutoJoin(client, "CGRID", fn);
|
|
211
|
+
expect(result).toBe("joined-ok");
|
|
212
|
+
expect(joinFn).toHaveBeenCalledTimes(1);
|
|
213
|
+
});
|
|
80
214
|
});
|
package/src/tools/send-task.ts
CHANGED
|
@@ -11,10 +11,11 @@ import {
|
|
|
11
11
|
getTaskById,
|
|
12
12
|
hasCapacity,
|
|
13
13
|
} from "@/be/db";
|
|
14
|
+
import { repointTrackerSyncBySwarmId } from "@/be/db-queries/tracker";
|
|
14
15
|
import { findDuplicateTask } from "@/tools/task-dedup";
|
|
15
16
|
import { ownerCtx, type ToolCtx } from "@/tools/task-tool-ctx";
|
|
16
17
|
import { createToolRegistrar } from "@/tools/utils";
|
|
17
|
-
import { AgentTaskSchema, FollowUpConfigSchema } from "@/types";
|
|
18
|
+
import { type AgentTask, AgentTaskSchema, FollowUpConfigSchema } from "@/types";
|
|
18
19
|
import { ModelTierSchema, splitLegacyModelAlias } from "../model-tiers";
|
|
19
20
|
|
|
20
21
|
export const sendTaskInputSchema = z.object({
|
|
@@ -103,6 +104,40 @@ export const sendTaskOutputSchema = z.object({
|
|
|
103
104
|
|
|
104
105
|
type SendTaskArgs = z.infer<typeof sendTaskInputSchema>;
|
|
105
106
|
|
|
107
|
+
const TRACKER_OWNERSHIP_TRANSFER_PARENT_STATUSES = new Set([
|
|
108
|
+
"superseded",
|
|
109
|
+
"completed",
|
|
110
|
+
"failed",
|
|
111
|
+
"cancelled",
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* When `send-task` creates a `resume` task whose parent is in a terminal state,
|
|
116
|
+
* move the parent's `tracker_sync` rows (Linear / Jira / GitHub outbound link)
|
|
117
|
+
* onto the new resume child so the re-delegated work keeps its external-tracker
|
|
118
|
+
* completion link. General-correct for any Lead re-delegation of a resume;
|
|
119
|
+
* specifically it completes the DES-523 tracker chain on the gone-agent path:
|
|
120
|
+
* original → R1 (pin) → R1 → original (reaper) → original → R2 (here). No-op for
|
|
121
|
+
* non-resume tasks or when the parent has no tracker_sync rows.
|
|
122
|
+
*/
|
|
123
|
+
function transferTrackerSyncToResumeChild(args: {
|
|
124
|
+
parentTaskId?: string;
|
|
125
|
+
taskType?: string;
|
|
126
|
+
child: AgentTask;
|
|
127
|
+
}): void {
|
|
128
|
+
if (args.taskType !== "resume" || !args.parentTaskId) return;
|
|
129
|
+
|
|
130
|
+
const parent = getTaskById(args.parentTaskId);
|
|
131
|
+
if (!parent || !TRACKER_OWNERSHIP_TRANSFER_PARENT_STATUSES.has(parent.status)) return;
|
|
132
|
+
|
|
133
|
+
const repointed = repointTrackerSyncBySwarmId(parent.id, args.child.id);
|
|
134
|
+
if (repointed > 0) {
|
|
135
|
+
console.log(
|
|
136
|
+
`[send-task] Repointed ${repointed} tracker_sync row(s) from terminal parent ${parent.id.slice(0, 8)} to resume child ${args.child.id.slice(0, 8)}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
106
141
|
export async function sendTaskHandler(
|
|
107
142
|
ctx: ToolCtx,
|
|
108
143
|
{
|
|
@@ -278,6 +313,11 @@ export async function sendTaskHandler(
|
|
|
278
313
|
slackUserId,
|
|
279
314
|
followUpConfig,
|
|
280
315
|
});
|
|
316
|
+
transferTrackerSyncToResumeChild({
|
|
317
|
+
parentTaskId: effectiveParentTaskId,
|
|
318
|
+
taskType,
|
|
319
|
+
child: newTask,
|
|
320
|
+
});
|
|
281
321
|
|
|
282
322
|
return {
|
|
283
323
|
success: true,
|
|
@@ -332,6 +372,11 @@ export async function sendTaskHandler(
|
|
|
332
372
|
slackUserId,
|
|
333
373
|
followUpConfig,
|
|
334
374
|
});
|
|
375
|
+
transferTrackerSyncToResumeChild({
|
|
376
|
+
parentTaskId: effectiveParentTaskId,
|
|
377
|
+
taskType,
|
|
378
|
+
child: newTask,
|
|
379
|
+
});
|
|
335
380
|
|
|
336
381
|
return {
|
|
337
382
|
success: true,
|
|
@@ -360,6 +405,11 @@ export async function sendTaskHandler(
|
|
|
360
405
|
slackUserId,
|
|
361
406
|
followUpConfig,
|
|
362
407
|
});
|
|
408
|
+
transferTrackerSyncToResumeChild({
|
|
409
|
+
parentTaskId: effectiveParentTaskId,
|
|
410
|
+
taskType,
|
|
411
|
+
child: newTask,
|
|
412
|
+
});
|
|
363
413
|
|
|
364
414
|
return {
|
|
365
415
|
success: true,
|
package/src/tools/templates.ts
CHANGED
|
@@ -160,3 +160,64 @@ Use \`get-task-details\` with taskId "{{task_id}}" for full details.`,
|
|
|
160
160
|
],
|
|
161
161
|
category: "task_lifecycle",
|
|
162
162
|
});
|
|
163
|
+
|
|
164
|
+
// ============================================================================
|
|
165
|
+
// Reroute-decision follow-up (Lead-routed crash-recovery, DES-523)
|
|
166
|
+
//
|
|
167
|
+
// Created by the heartbeat's stale-resume reaper when a crash-recovery resume
|
|
168
|
+
// was pinned to its original agent but never reclaimed within the grace window
|
|
169
|
+
// (the agent that looked recoverable never returned). Hands the Lead a DECISION
|
|
170
|
+
// task — not the raw work — telling it to re-delegate via `send-task` with an
|
|
171
|
+
// EXPLICIT agentId. The crashed agent's identity is provided as routing context
|
|
172
|
+
// only; the Lead picks who takes over. This work never falls back to the pool.
|
|
173
|
+
// ============================================================================
|
|
174
|
+
|
|
175
|
+
registerTemplate({
|
|
176
|
+
eventType: "task.reroute.decision",
|
|
177
|
+
header: "",
|
|
178
|
+
defaultBody: `Reroute decision: a crashed worker's task needs a new owner.
|
|
179
|
+
|
|
180
|
+
Crashed agent: {{original_agent_name}}
|
|
181
|
+
Identity / specialization: {{original_agent_identity}}
|
|
182
|
+
Original task ID: {{original_task_id}}
|
|
183
|
+
Trigger: {{reason}}
|
|
184
|
+
Task: "{{task_desc}}"
|
|
185
|
+
|
|
186
|
+
Resume generation: {{generation_next}} of {{max_generations}} (max).{{artifacts_block}}
|
|
187
|
+
|
|
188
|
+
## Your job
|
|
189
|
+
|
|
190
|
+
The worker that was handling this task crashed and did not come back within the grace window, so its pinned resume was never reclaimed. Pick an agent to take this work over and RE-DELEGATE it — do NOT execute it yourself, and do NOT leave routing to the default.
|
|
191
|
+
|
|
192
|
+
Use the crashed agent's identity above as context for who was on it and what kind of work it is. You may re-delegate to the same kind of agent, a peer, or whoever you judge appropriate — the choice is yours, but you MUST choose explicitly.
|
|
193
|
+
|
|
194
|
+
Dispatch via \`send-task\` with ALL of:
|
|
195
|
+
- an explicit \`agentId\` (the chosen worker) — REQUIRED. If you omit it, \`send-task\` auto-routes to the original task's agent, which is the dead worker, and the work re-strands.
|
|
196
|
+
- \`taskType: "resume"\`
|
|
197
|
+
- the tag \`resume-generation:{{generation_next}}\`
|
|
198
|
+
- \`parentTaskId: {{original_task_id}}\`
|
|
199
|
+
- do NOT inherit the original task's \`model\` (the new worker runs on its own).
|
|
200
|
+
|
|
201
|
+
This work will NOT fall back to the unassigned pool — you are the only re-delegation path.`,
|
|
202
|
+
variables: [
|
|
203
|
+
{ name: "original_agent_name", description: "Name or ID prefix of the crashed agent" },
|
|
204
|
+
{
|
|
205
|
+
name: "original_agent_identity",
|
|
206
|
+
description:
|
|
207
|
+
"Identity/specialization slice of the crashed agent (from identityMd), or a placeholder when none is recorded",
|
|
208
|
+
},
|
|
209
|
+
{ name: "original_task_id", description: "ID of the superseded original task" },
|
|
210
|
+
{ name: "reason", description: "Reroute trigger reason (e.g. crash_recovery)" },
|
|
211
|
+
{ name: "task_desc", description: "Original task description (truncated to 200 chars)" },
|
|
212
|
+
{
|
|
213
|
+
name: "generation_next",
|
|
214
|
+
description: "Next resume generation number (must be set on the dispatched resume)",
|
|
215
|
+
},
|
|
216
|
+
{ name: "max_generations", description: "Maximum resume generations before budget exhaustion" },
|
|
217
|
+
{
|
|
218
|
+
name: "artifacts_block",
|
|
219
|
+
description: "Formatted attachment list from the original task, or empty string",
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
category: "task_lifecycle",
|
|
223
|
+
});
|