@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.
@@ -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 = mock(() => Promise.resolve({}));
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 then retries fn exactly once", async () => {
26
- const joinFn = mock(() => Promise.resolve({}));
27
- const client = { conversations: { join: joinFn } } as unknown as WebClient;
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 method_not_supported_for_channel_type → descriptive error", async () => {
43
- const joinFn = mock(() => {
44
- throw makePlatformError("method_not_supported_for_channel_type");
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("non-not_in_channel error: rethrown without join", async () => {
57
- const joinFn = mock(() => Promise.resolve({}));
58
- const client = { conversations: { join: joinFn } } as unknown as WebClient;
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 = mock(() => Promise.resolve({}));
70
- const client = { conversations: { join: joinFn } } as unknown as WebClient;
71
- // Every call throws not_in_channel, but we only join once and retry once
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
  });
@@ -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,
@@ -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
+ });