@desplega.ai/agent-swarm 1.100.2 → 1.100.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  });
@@ -255,6 +255,231 @@ describe("SwarmScriptExecutor", () => {
255
255
  expect(scriptStep?.output).toMatchObject({ result: { seen: "mapped-value" } });
256
256
  });
257
257
 
258
+ test("exact object token outside swarm-script args is still stringified", async () => {
259
+ const wf = makeWorkflow({
260
+ nodes: [
261
+ {
262
+ id: "echo",
263
+ type: "echo",
264
+ config: { value: "{{trigger.payload}}" },
265
+ },
266
+ ],
267
+ });
268
+
269
+ const runId = await startWorkflowExecution(wf, { payload: { a: 1 } }, registry);
270
+ const run = getWorkflowRun(runId);
271
+ const steps = getWorkflowRunStepsByRunId(runId);
272
+ const echoStep = steps.find((step) => step.nodeId === "echo");
273
+
274
+ expect(run?.status).toBe("completed");
275
+ expect(echoStep?.status).toBe("completed");
276
+ expect(echoStep?.output).toEqual({ value: '{"a":1}' });
277
+ });
278
+
279
+ test("{{path}} args: object arg is injected as raw object, not JSON string", async () => {
280
+ await saveScript(
281
+ "echo-obj",
282
+ `export default async (args: { data: Record<string, unknown> }) => ({ isObject: typeof args.data === "object" && !Array.isArray(args.data), keys: Object.keys(args.data ?? {}) });`,
283
+ );
284
+ const wf = makeWorkflow({
285
+ nodes: [
286
+ {
287
+ id: "script",
288
+ type: "swarm-script",
289
+ config: {
290
+ scriptName: "echo-obj",
291
+ args: { data: "{{trigger.payload}}" },
292
+ },
293
+ },
294
+ ],
295
+ });
296
+
297
+ const runId = await startWorkflowExecution(wf, { payload: { a: 1, b: 2 } }, registry);
298
+ const run = getWorkflowRun(runId);
299
+ const steps = getWorkflowRunStepsByRunId(runId);
300
+ const scriptStep = steps.find((step) => step.nodeId === "script");
301
+
302
+ expect(run?.status).toBe("completed");
303
+ expect(scriptStep?.status).toBe("completed");
304
+ expect(scriptStep?.output).toMatchObject({ result: { isObject: true, keys: ["a", "b"] } });
305
+ });
306
+
307
+ test("{{path}} args: array arg is injected as raw array, not JSON string", async () => {
308
+ await saveScript(
309
+ "echo-arr",
310
+ `export default async (args: { items: string[] }) => ({ isArray: Array.isArray(args.items), length: args.items.length });`,
311
+ );
312
+ const wf = makeWorkflow({
313
+ nodes: [
314
+ {
315
+ id: "script",
316
+ type: "swarm-script",
317
+ config: {
318
+ scriptName: "echo-arr",
319
+ args: { items: "{{trigger.list}}" },
320
+ },
321
+ },
322
+ ],
323
+ });
324
+
325
+ const runId = await startWorkflowExecution(wf, { list: ["x", "y", "z"] }, registry);
326
+ const run = getWorkflowRun(runId);
327
+ const steps = getWorkflowRunStepsByRunId(runId);
328
+ const scriptStep = steps.find((step) => step.nodeId === "script");
329
+
330
+ expect(run?.status).toBe("completed");
331
+ expect(scriptStep?.status).toBe("completed");
332
+ expect(scriptStep?.output).toMatchObject({ result: { isArray: true, length: 3 } });
333
+ });
334
+
335
+ test("{{path}} args: empty array is injected as raw empty array with length 0, not '[]' string", async () => {
336
+ await saveScript(
337
+ "echo-empty-arr",
338
+ `export default async (args: { items: string[] }) => ({ isArray: Array.isArray(args.items), length: args.items.length });`,
339
+ );
340
+ const wf = makeWorkflow({
341
+ nodes: [
342
+ {
343
+ id: "script",
344
+ type: "swarm-script",
345
+ config: {
346
+ scriptName: "echo-empty-arr",
347
+ args: { items: "{{trigger.empty}}" },
348
+ },
349
+ },
350
+ ],
351
+ });
352
+
353
+ const runId = await startWorkflowExecution(wf, { empty: [] }, registry);
354
+ const run = getWorkflowRun(runId);
355
+ const steps = getWorkflowRunStepsByRunId(runId);
356
+ const scriptStep = steps.find((step) => step.nodeId === "script");
357
+
358
+ expect(run?.status).toBe("completed");
359
+ expect(scriptStep?.status).toBe("completed");
360
+ expect(scriptStep?.output).toMatchObject({ result: { isArray: true, length: 0 } });
361
+ });
362
+
363
+ test("{{path}} args: string scalar arg is injected as the string value", async () => {
364
+ await saveScript(
365
+ "echo-str",
366
+ `export default async (args: { name: string }) => ({ isString: typeof args.name === "string", value: args.name });`,
367
+ );
368
+ const wf = makeWorkflow({
369
+ nodes: [
370
+ {
371
+ id: "script",
372
+ type: "swarm-script",
373
+ config: {
374
+ scriptName: "echo-str",
375
+ args: { name: "{{trigger.ruleName}}" },
376
+ },
377
+ },
378
+ ],
379
+ });
380
+
381
+ const runId = await startWorkflowExecution(
382
+ wf,
383
+ { ruleName: "local-rules/cognitive-complexity" },
384
+ registry,
385
+ );
386
+ const run = getWorkflowRun(runId);
387
+ const steps = getWorkflowRunStepsByRunId(runId);
388
+ const scriptStep = steps.find((step) => step.nodeId === "script");
389
+
390
+ expect(run?.status).toBe("completed");
391
+ expect(scriptStep?.status).toBe("completed");
392
+ expect(scriptStep?.output).toMatchObject({
393
+ result: { isString: true, value: "local-rules/cognitive-complexity" },
394
+ });
395
+ });
396
+
397
+ test("{{path}} args: number scalar arg is injected as a number, not a string", async () => {
398
+ await saveScript(
399
+ "echo-num",
400
+ `export default async (args: { count: number }) => ({ isNumber: typeof args.count === "number", value: args.count });`,
401
+ );
402
+ const wf = makeWorkflow({
403
+ nodes: [
404
+ {
405
+ id: "script",
406
+ type: "swarm-script",
407
+ config: {
408
+ scriptName: "echo-num",
409
+ args: { count: "{{trigger.maxFiles}}" },
410
+ },
411
+ },
412
+ ],
413
+ });
414
+
415
+ const runId = await startWorkflowExecution(wf, { maxFiles: 3 }, registry);
416
+ const run = getWorkflowRun(runId);
417
+ const steps = getWorkflowRunStepsByRunId(runId);
418
+ const scriptStep = steps.find((step) => step.nodeId === "script");
419
+
420
+ expect(run?.status).toBe("completed");
421
+ expect(scriptStep?.status).toBe("completed");
422
+ expect(scriptStep?.output).toMatchObject({ result: { isNumber: true, value: 3 } });
423
+ });
424
+
425
+ test("{{path}} args: boolean scalar arg is injected as a boolean, not a string", async () => {
426
+ await saveScript(
427
+ "echo-bool",
428
+ `export default async (args: { enabled: boolean }) => ({ isBoolean: typeof args.enabled === "boolean", value: args.enabled });`,
429
+ );
430
+ const wf = makeWorkflow({
431
+ nodes: [
432
+ {
433
+ id: "script",
434
+ type: "swarm-script",
435
+ config: {
436
+ scriptName: "echo-bool",
437
+ args: { enabled: "{{trigger.enabled}}" },
438
+ },
439
+ },
440
+ ],
441
+ });
442
+
443
+ const runId = await startWorkflowExecution(wf, { enabled: false }, registry);
444
+ const run = getWorkflowRun(runId);
445
+ const steps = getWorkflowRunStepsByRunId(runId);
446
+ const scriptStep = steps.find((step) => step.nodeId === "script");
447
+
448
+ expect(run?.status).toBe("completed");
449
+ expect(scriptStep?.status).toBe("completed");
450
+ expect(scriptStep?.output).toMatchObject({ result: { isBoolean: true, value: false } });
451
+ });
452
+
453
+ test("{{path}} args: mixed string template still produces a string via interpolation", async () => {
454
+ await saveScript(
455
+ "echo-mixed",
456
+ `export default async (args: { label: string }) => ({ isString: typeof args.label === "string", value: args.label });`,
457
+ );
458
+ const wf = makeWorkflow({
459
+ nodes: [
460
+ {
461
+ id: "script",
462
+ type: "swarm-script",
463
+ config: {
464
+ scriptName: "echo-mixed",
465
+ args: { label: "rule-{{trigger.ruleName}}" },
466
+ },
467
+ },
468
+ ],
469
+ });
470
+
471
+ const runId = await startWorkflowExecution(wf, { ruleName: "no-explicit-any" }, registry);
472
+ const run = getWorkflowRun(runId);
473
+ const steps = getWorkflowRunStepsByRunId(runId);
474
+ const scriptStep = steps.find((step) => step.nodeId === "script");
475
+
476
+ expect(run?.status).toBe("completed");
477
+ expect(scriptStep?.status).toBe("completed");
478
+ expect(scriptStep?.output).toMatchObject({
479
+ result: { isString: true, value: "rule-no-explicit-any" },
480
+ });
481
+ });
482
+
258
483
  test("fsMode workspace-rw is rejected at config validation with a clear error message", async () => {
259
484
  await saveScript("noop", `export default async () => ({ ok: true });`);
260
485
  const executor = new SwarmScriptExecutor(deps);
@@ -205,6 +205,23 @@ describe("deepInterpolate", () => {
205
205
  expect(unresolved).toEqual([]);
206
206
  });
207
207
 
208
+ test("exact object token is stringified by default", () => {
209
+ const { value, unresolved } = deepInterpolate("{{body}}", { body: { message: "hello" } });
210
+ expect(value).toBe('{"message":"hello"}');
211
+ expect(unresolved).toEqual([]);
212
+ });
213
+
214
+ test("exact object token preserves raw value when requested", () => {
215
+ const body = { message: "hello" };
216
+ const { value, unresolved } = deepInterpolate(
217
+ "{{body}}",
218
+ { body },
219
+ { preserveRawTokens: true },
220
+ );
221
+ expect(value).toBe(body);
222
+ expect(unresolved).toEqual([]);
223
+ });
224
+
208
225
  test("mixed array (string + number + boolean)", () => {
209
226
  const { value, unresolved } = deepInterpolate(["{{name}}", 42, true, null], {
210
227
  name: "Test",
@@ -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
+ });
@@ -494,7 +494,7 @@ async function executeStep(
494
494
  }
495
495
 
496
496
  // 4. Deep-interpolate config using local context (not global ctx)
497
- const { value: interpolatedValue, unresolved } = deepInterpolate(node.config, interpolationCtx);
497
+ const { value: interpolatedValue, unresolved } = interpolateNodeConfig(node, interpolationCtx);
498
498
  const interpolatedConfig = interpolatedValue as Record<string, unknown>;
499
499
  const executionCtx: Record<string, unknown> = { ...ctx, ...interpolationCtx };
500
500
 
@@ -709,6 +709,27 @@ export function findReadyNodes(
709
709
  });
710
710
  }
711
711
 
712
+ export function interpolateNodeConfig(
713
+ node: Pick<WorkflowNode, "type" | "config">,
714
+ interpolationCtx: Record<string, unknown>,
715
+ ): { value: unknown; unresolved: string[] } {
716
+ if (node.type !== "swarm-script" || !Object.hasOwn(node.config, "args")) {
717
+ return deepInterpolate(node.config, interpolationCtx);
718
+ }
719
+
720
+ const { args, ...configWithoutArgs } = node.config;
721
+ const configResult = deepInterpolate(configWithoutArgs, interpolationCtx);
722
+ const argsResult = deepInterpolate(args, interpolationCtx, { preserveRawTokens: true });
723
+
724
+ return {
725
+ value: {
726
+ ...(configResult.value as Record<string, unknown>),
727
+ args: argsResult.value,
728
+ },
729
+ unresolved: [...configResult.unresolved, ...argsResult.unresolved],
730
+ };
731
+ }
732
+
712
733
  // ─── Helpers ───────────────────────────────────────────────
713
734
 
714
735
  function timeoutPromise(ms: number): Promise<never> {
@@ -8,9 +8,8 @@ import {
8
8
  import type { RetryPolicy } from "../types";
9
9
  import { checkpointStep, checkpointStepFailure } from "./checkpoint";
10
10
  import { getSuccessors } from "./definition";
11
- import { walkGraph } from "./engine";
11
+ import { interpolateNodeConfig, walkGraph } from "./engine";
12
12
  import type { ExecutorRegistry } from "./executors/registry";
13
- import { deepInterpolate } from "./template";
14
13
  import { runStepValidation } from "./validation";
15
14
 
16
15
  let pollerTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -62,7 +61,7 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
62
61
  const ctx = (run.context ?? {}) as Record<string, unknown>;
63
62
 
64
63
  // Deep-interpolate config
65
- const { value: interpolatedValue } = deepInterpolate(node.config, ctx);
64
+ const { value: interpolatedValue } = interpolateNodeConfig(node, ctx);
66
65
  const interpolatedConfig = interpolatedValue as Record<string, unknown>;
67
66
 
68
67
  // Get executor and re-run