@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.
- 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/tests/workflow-swarm-script.test.ts +225 -0
- package/src/tests/workflow-template.test.ts +17 -0
- package/src/tools/send-task.ts +51 -1
- package/src/tools/templates.ts +61 -0
- package/src/workflows/engine.ts +22 -1
- package/src/workflows/retry-poller.ts +2 -3
- package/src/workflows/template.ts +48 -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
|
});
|
|
@@ -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",
|
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
|
+
});
|
package/src/workflows/engine.ts
CHANGED
|
@@ -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 } =
|
|
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 } =
|
|
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
|