@desplega.ai/agent-swarm 1.147.0 → 1.149.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/README.md +13 -1
  2. package/dist/{acp-adapter-9n319wqc.js → acp-adapter-4jncb126.js} +12 -5
  3. package/dist/{actions-2vxqvpr9.js → actions-31vp3ae7.js} +10 -9
  4. package/dist/{app-w066xfy0.js → app-1hh67x8w.js} +6 -6
  5. package/dist/{assistant-kt31dj2h.js → assistant-z66dmz6p.js} +13 -11
  6. package/dist/{boot-reembed-az5rassp.js → boot-reembed-fqzxxzjh.js} +7 -7
  7. package/dist/{boot-reembed-j3mm3rfz.js → boot-reembed-wxa8ya0s.js} +8 -8
  8. package/dist/{boot-scrub-logs-b6h54817.js → boot-scrub-logs-jnyndh79.js} +5 -5
  9. package/dist/{claude-adapter-w1z04ann.js → claude-adapter-3qpa9w0j.js} +9 -9
  10. package/dist/{claude-managed-adapter-sactwn31.js → claude-managed-adapter-qvsmh6mv.js} +2 -2
  11. package/dist/{claude-sdk-session-f0cc1kec.js → claude-sdk-session-2yxgjs5d.js} +9 -9
  12. package/dist/{cli-y6b6fb76.js → cli-08b07b4r.js} +1 -1
  13. package/dist/{cli-8fc8de14.js → cli-0z2v4nhw.js} +1 -1
  14. package/dist/{cli-de14znh8.js → cli-19f354qr.js} +2 -2
  15. package/dist/{cli-hbv0kq6w.js → cli-2qts1hys.js} +3 -3
  16. package/dist/{cli-kpv03zhj.js → cli-3apbzgyk.js} +27 -15
  17. package/dist/{cli-1th1728g.js → cli-3q5ejx9p.js} +2 -2
  18. package/dist/{cli-d8brzxjb.js → cli-4wtq5jjv.js} +2 -2
  19. package/dist/{cli-ct0et58h.js → cli-5my3bjsd.js} +1 -1
  20. package/dist/{cli-cn6mmc7f.js → cli-6664w7y4.js} +1 -1
  21. package/dist/{cli-yz3djrm0.js → cli-6pqcb4q0.js} +53 -13
  22. package/dist/{cli-719k9j2c.js → cli-7smrsr25.js} +3 -3
  23. package/dist/{cli-rw51bq3j.js → cli-9fy6nk6g.js} +1 -1
  24. package/dist/{cli-zhjmqxzh.js → cli-9hspd3dp.js} +2 -2
  25. package/dist/{cli-3tgwnf43.js → cli-aae6hz6a.js} +4 -4
  26. package/dist/{cli-89xfd7f9.js → cli-cr12paw2.js} +5 -5
  27. package/dist/{cli-b5bq9crk.js → cli-d8ftsp62.js} +8 -8
  28. package/dist/{cli-4nr988h6.js → cli-e95cx1eb.js} +3 -3
  29. package/dist/{cli-n8508nre.js → cli-gjhjfeeg.js} +1 -1
  30. package/dist/{cli-p1d5b073.js → cli-gv88e1vf.js} +1 -1
  31. package/dist/{cli-rvqz34y5.js → cli-jecc01cb.js} +1 -1
  32. package/dist/{cli-8v0g1yc8.js → cli-k0cr4kat.js} +1 -1
  33. package/dist/{cli-mmemxxdc.js → cli-kgz4np97.js} +4 -4
  34. package/dist/{cli-w9kb3bk1.js → cli-kjwt6hdf.js} +3 -1
  35. package/dist/{cli-f7dsy61y.js → cli-mnmsfd1w.js} +6 -2
  36. package/dist/{cli-54wwve05.js → cli-n3dz2942.js} +37 -26
  37. package/dist/{cli-12fz972k.js → cli-nye12xk5.js} +8 -5
  38. package/dist/{cli-tzvk9haz.js → cli-phsxnkp6.js} +20 -20
  39. package/dist/{cli-53s590z8.js → cli-q50ef8g0.js} +1 -1
  40. package/dist/{cli-f146mn5s.js → cli-qaacms9y.js} +7 -6
  41. package/dist/{cli-xqcq9y5e.js → cli-r2ap2czm.js} +1 -0
  42. package/dist/{cli-m8yrsg97.js → cli-sd5pv3b1.js} +3 -3
  43. package/dist/{cli-0b6y7kxm.js → cli-xm87hzbw.js} +1 -1
  44. package/dist/{cli-ftht3zzj.js → cli-yab68w40.js} +285 -11
  45. package/dist/{cli-atcve9y8.js → cli-yb9qhqam.js} +227 -59
  46. package/dist/{cli-smby5m17.js → cli-ygweqcn4.js} +133 -22
  47. package/dist/{cli-dttph1bw.js → cli-ykxrnd1q.js} +2 -2
  48. package/dist/{cli-m9sxbkhm.js → cli-zvz62chv.js} +3 -3
  49. package/dist/cli.js +16 -14
  50. package/dist/{codex-adapter-zw4dwsx7.js → codex-adapter-8meawydj.js} +4 -4
  51. package/dist/{codex-hook-bw8p5nh4.js → codex-hook-hfrt9shr.js} +2 -2
  52. package/dist/{codex-session-runner-k981s65v.js → codex-session-runner-gjgxyp5j.js} +4 -4
  53. package/dist/{commands-sykrxx7e.js → commands-hz3nb95f.js} +5 -5
  54. package/dist/{db-sfbsc8wt.js → db-ggz97zbm.js} +5 -5
  55. package/dist/{e2b-6hb103d8.js → e2b-p04vmtx0.js} +1 -1
  56. package/dist/{handlers-0aspcejm.js → handlers-nbzdzn9k.js} +14 -11
  57. package/dist/{hook-hxccs7m5.js → hook-bks2q1f6.js} +7 -7
  58. package/dist/{hook-4nvqj2sb.js → hook-kq9wp0ep.js} +8 -8
  59. package/dist/{http-y06z517d.js → http-hrf77v87.js} +96 -45
  60. package/dist/{index-tce1rss4.js → index-6nsbg88z.js} +443 -144
  61. package/dist/{index-x5j894zf.js → index-8hmeg8j1.js} +14 -14
  62. package/dist/{index-k8znfx0z.js → index-cqtebbhb.js} +13 -13
  63. package/dist/{index-hssww7kj.js → index-n28pw1zt.js} +15 -15
  64. package/dist/{keepalive-w733ax66.js → keepalive-dxc12gvg.js} +7 -7
  65. package/dist/{lead-75bjqa9d.js → lead-yckfn18d.js} +32 -32
  66. package/dist/{maintenance-w4f1zjck.js → maintenance-6kfb2m5e.js} +8 -8
  67. package/dist/{oauth-refresh-sweep-wdetky26.js → oauth-refresh-sweep-1x1ac722.js} +6 -6
  68. package/dist/{onboard-bb5net8s.js → onboard-dcyt1609.js} +4 -3
  69. package/dist/{opencode-adapter-fef1mrn2.js → opencode-adapter-dce4wskd.js} +2 -2
  70. package/dist/{otel-impl-0w7c14ap.js → otel-impl-tbk3e5dr.js} +2 -2
  71. package/dist/{pi-mono-adapter-2has4gnp.js → pi-mono-adapter-zvshk5cj.js} +2 -2
  72. package/dist/{pricing-refresh-9nzsrgcq.js → pricing-refresh-kaqg567e.js} +7 -7
  73. package/dist/{rbac-roles-z1jrffbt.js → rbac-roles-ammq6rd2.js} +5 -5
  74. package/dist/{rbac-roles-r3hqjp09.js → rbac-roles-cf97v1vd.js} +6 -6
  75. package/dist/{render-v2-vbr89fnb.js → render-v2-80w603vy.js} +6 -6
  76. package/dist/{seed-pricing-g62hy1pk.js → seed-pricing-zbrn83xv.js} +6 -6
  77. package/dist/{setup-gnfqnptp.js → setup-fe66kczs.js} +2 -2
  78. package/dist/{worker-kznxp4qm.js → worker-qmb2d1e2.js} +32 -32
  79. package/dist/{x-n2phr5vm.js → x-n3e0v3xa.js} +2 -2
  80. package/openapi.json +46 -4
  81. package/package.json +3 -1
  82. package/src/agentmail/handlers.ts +5 -0
  83. package/src/automation-preflight-alert.ts +41 -0
  84. package/src/be/automation-preflight.ts +3 -3
  85. package/src/be/budget-refusal-notify.ts +1 -0
  86. package/src/be/db/tasks/read.ts +5 -0
  87. package/src/be/db.ts +132 -2
  88. package/src/be/migrations/150_deferred_task_waits.sql +18 -0
  89. package/src/be/migrations/151_repair_scheduled_task_required_params.sql +39 -0
  90. package/src/be/migrations/152_routing_decisions.sql +44 -0
  91. package/src/be/scripts/typecheck.ts +33 -22
  92. package/src/be/seed-scripts/catalog/delegate.ts +5 -1
  93. package/src/be/seed-skills/bundled-files.generated.json +10 -0
  94. package/src/be/seed-skills/index.ts +3 -0
  95. package/src/be/steering.ts +1 -0
  96. package/src/be/swarm-config-guard.ts +11 -0
  97. package/src/commands/onboard/steps/post-task.tsx +1 -0
  98. package/src/github/handlers.ts +9 -0
  99. package/src/gitlab/handlers.ts +4 -0
  100. package/src/heartbeat/heartbeat.ts +8 -0
  101. package/src/http/approval-requests.ts +1 -0
  102. package/src/http/apps.ts +1 -0
  103. package/src/http/config.ts +15 -6
  104. package/src/http/mcp.ts +31 -4
  105. package/src/http/script-runs.ts +6 -0
  106. package/src/http/tasks.ts +52 -30
  107. package/src/integrations/kapso/inbound.ts +1 -0
  108. package/src/jira/sync.ts +2 -0
  109. package/src/linear/sync.ts +2 -0
  110. package/src/prompts/session-templates.ts +29 -15
  111. package/src/scheduler/deferred-task-waits.ts +123 -0
  112. package/src/scheduler/schedule-task.ts +42 -0
  113. package/src/scheduler/scheduler.ts +22 -31
  114. package/src/script-workflows/workflow-ctx.ts +2 -0
  115. package/src/scripts-runtime/types/stdlib.d.ts +38 -24
  116. package/src/scripts-runtime/types/swarm-sdk.d.ts +38 -24
  117. package/src/server.ts +10 -1
  118. package/src/slack/actions.ts +1 -0
  119. package/src/slack/assistant.ts +2 -0
  120. package/src/slack/handlers.ts +3 -0
  121. package/src/slack/thread-buffer.ts +2 -0
  122. package/src/tasks/worker-follow-up.ts +9 -0
  123. package/src/tests/acp-adapter.test.ts +35 -6
  124. package/src/tests/acp-session-token.test.ts +67 -0
  125. package/src/tests/asset-key-api.test.ts +15 -2
  126. package/src/tests/asset-key-mcp.test.ts +2 -0
  127. package/src/tests/automation-preflight-alert.test.ts +91 -0
  128. package/src/tests/claude-worker-parity.test.ts +3 -0
  129. package/src/tests/config-session-auth.test.ts +143 -0
  130. package/src/tests/defer-task.test.ts +117 -6
  131. package/src/tests/deferred-task-wake.test.ts +456 -0
  132. package/src/tests/heartbeat-reroute-decision.test.ts +1 -0
  133. package/src/tests/http-api-integration.test.ts +33 -4
  134. package/src/tests/mcp-input-ergonomics.test.ts +386 -0
  135. package/src/tests/model-control.test.ts +18 -5
  136. package/src/tests/opencode-adapter.test.ts +2 -2
  137. package/src/tests/promote-draft-task-route.test.ts +4 -0
  138. package/src/tests/prompt-template-session.test.ts +13 -4
  139. package/src/tests/rbac-wire-e2e.test.ts +2 -2
  140. package/src/tests/routing-decision-persistence.test.ts +520 -0
  141. package/src/tests/routing-reason-contract.test.ts +89 -0
  142. package/src/tests/routing-reason-inventory.test.ts +55 -0
  143. package/src/tests/schedule-target-type.test.ts +48 -1
  144. package/src/tests/scheduled-task-required-params-migration.test.ts +182 -0
  145. package/src/tests/scheduled-tasks.test.ts +9 -5
  146. package/src/tests/script-connections.test.ts +4 -0
  147. package/src/tests/script-runs-http.test.ts +31 -0
  148. package/src/tests/scripts-typecheck.test.ts +11 -0
  149. package/src/tests/secret-scrubber.test.ts +12 -0
  150. package/src/tests/send-task-output-schema.test.ts +66 -1
  151. package/src/tests/send-task-requested-by.test.ts +1 -0
  152. package/src/tests/send-task-slack-routing-guard.test.ts +2 -0
  153. package/src/tests/store-progress-blocked-waiting-gate.test.ts +151 -0
  154. package/src/tests/swarm-tool-result-gate.test.ts +24 -0
  155. package/src/tests/task-tool-manifest.test.ts +47 -0
  156. package/src/tests/task-tool-preload-mcp.test.ts +125 -0
  157. package/src/tests/task-tools-ctx.test.ts +2 -0
  158. package/src/tests/tool-output-agent-id.test.ts +1 -0
  159. package/src/tests/ui-followup-lead-delegation.test.ts +6 -1
  160. package/src/tests/workflow-agent-task.test.ts +24 -1
  161. package/src/tests/workflow-engine-v2.test.ts +58 -2
  162. package/src/tools/accept-steer.ts +0 -1
  163. package/src/tools/defer-task.ts +73 -21
  164. package/src/tools/memory-get.ts +10 -3
  165. package/src/tools/memory-rate.ts +19 -7
  166. package/src/tools/memory-search.ts +2 -1
  167. package/src/tools/memory-store.ts +25 -5
  168. package/src/tools/send-task.ts +40 -2
  169. package/src/tools/skills/skill-publish.ts +1 -0
  170. package/src/tools/store-progress.ts +78 -4
  171. package/src/tools/templates.ts +2 -1
  172. package/src/tools/utils.ts +33 -2
  173. package/src/types.ts +14 -1
  174. package/src/utils/acp-session-token.ts +14 -4
  175. package/src/utils/secret-scrubber.ts +2 -0
  176. package/src/utils/task-tool-manifest.ts +33 -0
  177. package/src/workflows/engine.ts +4 -1
  178. package/src/workflows/executors/agent-task.ts +7 -1
  179. package/templates/ai-toolbox.manifest.json +6 -1
  180. package/templates/skills/comms/config.json +18 -0
  181. package/templates/skills/comms/content.md +74 -0
  182. package/templates/skills/comms/files/references/ste-rules.md +73 -0
  183. package/templates/skills/comms/files/references/visual-shapes.md +112 -0
  184. package/templates/skills/swarm-scripts/SKILL.md +3 -2
  185. package/templates/skills/swarm-scripts/content.md +3 -2
@@ -22,6 +22,7 @@ import {
22
22
  createWorkflow,
23
23
  createWorkflowRun,
24
24
  createWorkflowRunStep,
25
+ getLogsByTaskId,
25
26
  getScheduledTasks,
26
27
  getTaskById,
27
28
  initDb,
@@ -183,7 +184,9 @@ describe("defer-task handler", () => {
183
184
  const stored = await getTaskById(task.id);
184
185
  expect(stored?.status).toBe("completed");
185
186
  // Summary first, then the deferral status line, then the checks.
186
- expect(stored?.output?.startsWith(`${SUMMARY}\n\n`)).toBe(true);
187
+ expect(stored?.output).toBe(
188
+ `${SUMMARY}\n\nDeferred until ${schedule.nextRunAt} (schedule ${schedule.id}). Pending: deploy 42 is still running\n\nChecks:\n- deploy 42 status is green\n- smoke tests pass`,
189
+ );
187
190
  expect(stored?.output).toContain("Pending: deploy 42 is still running");
188
191
  expect(stored?.output).toContain(schedule.nextRunAt!);
189
192
  expect(stored?.output).toContain(schedule.id);
@@ -313,7 +316,7 @@ describe("defer-task handler", () => {
313
316
  expect((await getTaskById(task.id))?.status).toBe("in_progress");
314
317
  });
315
318
 
316
- test("a task with an outputSchema refuses a prose deferral and books no schedule", async () => {
319
+ test("a task with an outputSchema requires explicit output and books no schedule", async () => {
317
320
  const task = await startedTask("structured output task", agentId, {
318
321
  outputSchema: {
319
322
  type: "object",
@@ -328,13 +331,103 @@ describe("defer-task handler", () => {
328
331
  )) as DeferTaskResult;
329
332
 
330
333
  expect(result.structuredContent.success).toBe(false);
331
- expect(result.structuredContent.message).toContain("outputSchema");
334
+ expect(result.structuredContent.message).toContain(
335
+ "Call defer-task with output: a JSON string",
336
+ );
332
337
  expect((await schedulesForTask(task.id)).length).toBe(0);
333
338
  const stored = await getTaskById(task.id);
334
339
  expect(stored?.status).toBe("in_progress");
335
340
  expect(stored?.output).toBeFalsy();
336
341
  });
337
342
 
343
+ test("valid schema output is preserved verbatim and deferral details are logged", async () => {
344
+ const task = await startedTask("structured deferral", agentId, {
345
+ outputSchema: {
346
+ type: "object",
347
+ required: ["result"],
348
+ properties: { result: { type: "string" } },
349
+ },
350
+ });
351
+ const output = ' { "result": "deploy pending" }\n';
352
+ const result = (await buildTool().handler(
353
+ {
354
+ taskId: task.id,
355
+ delayMs: 60_000,
356
+ summary: SUMMARY,
357
+ note: "pending deploy",
358
+ checks: ["check deploy"],
359
+ output,
360
+ },
361
+ meta(),
362
+ )) as DeferTaskResult;
363
+
364
+ expect(result.structuredContent.success).toBe(true);
365
+ const stored = await getTaskById(task.id);
366
+ expect(stored?.status).toBe("completed");
367
+ expect(stored?.output).toBe(output);
368
+ const schedules = await schedulesForTask(task.id);
369
+ expect(schedules).toHaveLength(1);
370
+ const schedule = schedules[0]!;
371
+ expect(schedule.taskTemplate).toBe(
372
+ `Resume task ${task.id}: pending deploy\n\nChecks:\n- check deploy`,
373
+ );
374
+ const logs = (await getLogsByTaskId(task.id)).filter(
375
+ (log) => log.eventType === "task_progress",
376
+ );
377
+ expect(logs).toHaveLength(1);
378
+ expect(logs[0]!.newValue).toBe(
379
+ `${SUMMARY}\n\nDeferred until ${schedule.nextRunAt} (schedule ${schedule.id}). Pending: pending deploy\n\nChecks:\n- check deploy`,
380
+ );
381
+ });
382
+
383
+ test.each([
384
+ "not JSON",
385
+ '{"result": 42}',
386
+ ])("invalid schema output %s has no side effects", async (output) => {
387
+ const task = await startedTask("invalid structured deferral", agentId, {
388
+ outputSchema: {
389
+ type: "object",
390
+ required: ["result"],
391
+ properties: { result: { type: "string" } },
392
+ },
393
+ });
394
+ const result = (await buildTool().handler(
395
+ { taskId: task.id, delayMs: 60_000, summary: SUMMARY, note: "pending", output },
396
+ meta(),
397
+ )) as DeferTaskResult;
398
+ expect(result.structuredContent.success).toBe(false);
399
+ expect(result.structuredContent.message).toContain("outputSchema");
400
+ expect(await schedulesForTask(task.id)).toHaveLength(0);
401
+ expect((await getTaskById(task.id))?.status).toBe("in_progress");
402
+ expect((await getTaskById(task.id))?.output).toBeFalsy();
403
+ expect(
404
+ (await getLogsByTaskId(task.id)).filter((log) => log.eventType === "task_progress"),
405
+ ).toHaveLength(0);
406
+ });
407
+
408
+ test("JSON string schema output can defer", async () => {
409
+ const task = await startedTask("string output", agentId, { outputSchema: { type: "string" } });
410
+ const output = '"pending"';
411
+ const result = (await buildTool().handler(
412
+ { taskId: task.id, delayMs: 60_000, summary: SUMMARY, note: "pending", output },
413
+ meta(),
414
+ )) as DeferTaskResult;
415
+ expect(result.structuredContent.success).toBe(true);
416
+ expect((await getTaskById(task.id))?.output).toBe(output);
417
+ });
418
+
419
+ test("explicit output is ignored without a schema", async () => {
420
+ const task = await startedTask("unstructured output");
421
+ const result = (await buildTool().handler(
422
+ { taskId: task.id, delayMs: 60_000, summary: SUMMARY, note: "pending", output: '"ignored"' },
423
+ meta(),
424
+ )) as DeferTaskResult;
425
+ expect(result.structuredContent.success).toBe(true);
426
+ expect((await getTaskById(task.id))?.output).toBe(
427
+ `${SUMMARY}\n\nDeferred until ${result.structuredContent.nextRunAt} (schedule ${result.structuredContent.scheduleId}). Pending: pending`,
428
+ );
429
+ });
430
+
338
431
  test("the wake-up schedule carries modelTier but never the parent's concrete model", async () => {
339
432
  const task = await startedTask("pinned model task", agentId, {
340
433
  model: "claude-sonnet-5",
@@ -353,15 +446,27 @@ describe("defer-task handler", () => {
353
446
  });
354
447
 
355
448
  test("a competing terminal transition between schedule creation and completion aborts the loser and books no orphan schedule", async () => {
356
- const task = await startedTask("race target");
449
+ const task = await startedTask("race target", agentId, { outputSchema: { type: "string" } });
357
450
 
358
451
  const [first, second] = (await Promise.all([
359
452
  buildTool().handler(
360
- { taskId: task.id, delayMs: 60_000, summary: "first writer", note: "pending A" },
453
+ {
454
+ taskId: task.id,
455
+ delayMs: 60_000,
456
+ summary: "first writer",
457
+ note: "pending A",
458
+ output: '"first"',
459
+ },
361
460
  meta(),
362
461
  ),
363
462
  buildTool().handler(
364
- { taskId: task.id, delayMs: 90_000, summary: "second writer", note: "pending B" },
463
+ {
464
+ taskId: task.id,
465
+ delayMs: 90_000,
466
+ summary: "second writer",
467
+ note: "pending B",
468
+ output: '"second"',
469
+ },
365
470
  meta(),
366
471
  ),
367
472
  ])) as [DeferTaskResult, DeferTaskResult];
@@ -378,6 +483,12 @@ describe("defer-task handler", () => {
378
483
  expect(failed.length).toBe(1);
379
484
  expect(failed[0]!.structuredContent.message).toContain("terminal state");
380
485
 
486
+ const logs = (await getLogsByTaskId(task.id)).filter(
487
+ (log) => log.eventType === "task_progress",
488
+ );
489
+ expect(logs).toHaveLength(1);
490
+ expect(logs[0]!.newValue).toContain(succeeded[0]!.structuredContent.scheduleId!);
491
+
381
492
  // The loser's schedule INSERT rolled back with its transaction — only
382
493
  // the winner's wake-up was committed. No orphan schedule.
383
494
  const schedules = await schedulesForTask(task.id);
@@ -0,0 +1,456 @@
1
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import {
7
+ cancelTask,
8
+ closeDb,
9
+ completeTask,
10
+ createAgent,
11
+ createScheduledTask,
12
+ createTaskExtended,
13
+ deleteScheduledTask,
14
+ failTask,
15
+ getDbClient,
16
+ getLogsByTaskId,
17
+ getScheduledTaskById,
18
+ getTaskById,
19
+ initDb,
20
+ startTask,
21
+ updateScheduledTask,
22
+ } from "../be/db";
23
+ import {
24
+ dispatchDeferredTaskWait,
25
+ initDeferredTaskWaits,
26
+ reconcileDeferredTaskWaits,
27
+ stopDeferredTaskWaits,
28
+ type TaskWakeEvent,
29
+ } from "../scheduler/deferred-task-waits";
30
+ import { dispatchScheduleTarget, executeSchedule } from "../scheduler/scheduler";
31
+ import { registerDeferTaskTool } from "../tools/defer-task";
32
+
33
+ type Result = { structuredContent: { success: boolean; message: string; scheduleId?: string } };
34
+ type Tool = {
35
+ handler: (args: unknown, extra: unknown) => Promise<Result>;
36
+ inputSchema: { safeParse: (args: unknown) => { success: boolean } };
37
+ };
38
+ const templateGlobals = globalThis as typeof globalThis & { __testMigrationTemplate?: Uint8Array };
39
+ let savedTemplate: Uint8Array | undefined;
40
+ let dir: string;
41
+ let path: string;
42
+ let agentId: string;
43
+ let tool: Tool;
44
+
45
+ beforeAll(async () => {
46
+ dir = await mkdtemp(join(tmpdir(), "task-wake-"));
47
+ path = join(dir, "test.sqlite");
48
+ // The preload normally deserializes a fresh in-memory DB on every initDb.
49
+ // Use a real file here so close/reopen verifies durable restart recovery.
50
+ savedTemplate = templateGlobals.__testMigrationTemplate;
51
+ if (savedTemplate) await Bun.write(path, savedTemplate);
52
+ delete templateGlobals.__testMigrationTemplate;
53
+ initDb(path);
54
+ agentId = (
55
+ await createAgent({
56
+ name: "Wake worker",
57
+ isLead: false,
58
+ status: "busy",
59
+ capabilities: [],
60
+ maxTasks: 10,
61
+ })
62
+ ).id;
63
+ const server = new McpServer({ name: "wake-test", version: "1.0.0" });
64
+ registerDeferTaskTool(server);
65
+ tool = (server as unknown as { _registeredTools: Record<string, Tool> })._registeredTools[
66
+ "defer-task"
67
+ ]!;
68
+ });
69
+ afterEach(() => stopDeferredTaskWaits());
70
+ afterAll(async () => {
71
+ closeDb();
72
+ templateGlobals.__testMigrationTemplate = savedTemplate;
73
+ await rm(dir, { recursive: true, force: true });
74
+ });
75
+
76
+ async function activeTask() {
77
+ const task = await createTaskExtended("work in progress", {
78
+ agentId,
79
+ modelTier: "smart",
80
+ priority: 70,
81
+ });
82
+ await startTask(task.id);
83
+ return task;
84
+ }
85
+ async function defer(
86
+ taskId: string,
87
+ watchedId: string,
88
+ event: TaskWakeEvent = "settled",
89
+ extra: Record<string, unknown> = {},
90
+ ) {
91
+ return tool.handler(
92
+ {
93
+ taskId,
94
+ wakeOn: { taskId: watchedId, event },
95
+ delayMs: 3600000,
96
+ summary: "Work submitted",
97
+ note: "Check the producer result",
98
+ checks: ["verify output"],
99
+ ...extra,
100
+ },
101
+ { sessionId: crypto.randomUUID(), requestInfo: { headers: { "x-agent-id": agentId } } },
102
+ );
103
+ }
104
+ async function fixture(event: TaskWakeEvent = "settled") {
105
+ const parent = await activeTask();
106
+ const producer = await activeTask();
107
+ const result = await defer(parent.id, producer.id, event);
108
+ expect(result.structuredContent.success).toBe(true);
109
+ const schedule = await getScheduledTaskById(result.structuredContent.scheduleId!);
110
+ return { parent, producer, schedule: schedule! };
111
+ }
112
+ async function children(scheduleId: string) {
113
+ const rows = await getDbClient().query<{ id: string }>(
114
+ "SELECT id FROM agent_tasks WHERE scheduleId = ?",
115
+ [scheduleId],
116
+ );
117
+ return Promise.all(rows.map((row) => getTaskById(row.id)));
118
+ }
119
+ async function awaitChild(scheduleId: string) {
120
+ const deadline = Date.now() + 3000;
121
+ while (Date.now() < deadline) {
122
+ const found = await children(scheduleId);
123
+ if (found.length) return found[0]!;
124
+ await Bun.sleep(5);
125
+ }
126
+ throw new Error("Event bus did not create a wake-up child");
127
+ }
128
+ async function waitState(scheduleId: string) {
129
+ return getDbClient().get<{
130
+ taskId: string;
131
+ status: string;
132
+ firedBy: string | null;
133
+ childTaskId: string | null;
134
+ resolvedAt: string | null;
135
+ updated_at: string;
136
+ }>("SELECT * FROM deferred_task_waits WHERE scheduleId = ?", [scheduleId]);
137
+ }
138
+
139
+ describe("defer-task wakeOn", () => {
140
+ test("ordinary schedules neither suppress a completion nor retarget its waiters", async () => {
141
+ const { producer, schedule } = await fixture();
142
+ const ordinary = await createScheduledTask({
143
+ name: `ordinary-${crypto.randomUUID()}`,
144
+ taskTemplate: "Related scheduled work",
145
+ taskType: "maintenance",
146
+ intervalMs: 60000,
147
+ parentTaskId: producer.id,
148
+ });
149
+ await dispatchScheduleTarget(ordinary);
150
+ expect(await waitState(schedule.id)).toMatchObject({ taskId: producer.id, status: "pending" });
151
+ await completeTask(producer.id, "done");
152
+ await reconcileDeferredTaskWaits(producer.id);
153
+ expect(await children(schedule.id)).toHaveLength(1);
154
+ });
155
+
156
+ test("two waiters follow a time-based deferral and fire once on the resume task's completion", async () => {
157
+ await initDeferredTaskWaits();
158
+ const { producer, schedule } = await fixture();
159
+ const second = await defer((await activeTask()).id, producer.id, "task.completed");
160
+ const secondSchedule = (await getScheduledTaskById(second.structuredContent.scheduleId!))!;
161
+ const deferred = await defer(producer.id, producer.id, "settled", { wakeOn: undefined });
162
+ expect(deferred.structuredContent.success).toBe(true);
163
+ const successor = (await getScheduledTaskById(deferred.structuredContent.scheduleId!))!;
164
+
165
+ await reconcileDeferredTaskWaits(producer.id);
166
+ for (const waiter of [schedule, secondSchedule]) {
167
+ expect(await children(waiter.id)).toHaveLength(0);
168
+ expect(await waitState(waiter.id)).toMatchObject({
169
+ taskId: producer.id,
170
+ status: "pending",
171
+ firedBy: null,
172
+ });
173
+ }
174
+
175
+ await executeSchedule(successor);
176
+ const resumed = (await children(successor.id))[0]!;
177
+ for (const waiter of [schedule, secondSchedule]) {
178
+ expect(await waitState(waiter.id)).toMatchObject({ taskId: resumed.id, status: "pending" });
179
+ expect((await getScheduledTaskById(waiter.id))?.nextRunAt).toBe(waiter.nextRunAt);
180
+ }
181
+ await reconcileDeferredTaskWaits();
182
+ expect(await children(schedule.id)).toHaveLength(0);
183
+ await completeTask(resumed.id, "real result");
184
+ for (const waiter of [schedule, secondSchedule]) {
185
+ const child = await awaitChild(waiter.id);
186
+ expect(child.task).toContain(`Wake-up cause: task.completed for task ${resumed.id}`);
187
+ const fired = await waitState(waiter.id);
188
+ expect(fired).toMatchObject({
189
+ status: "fired",
190
+ firedBy: "task.completed",
191
+ childTaskId: child.id,
192
+ });
193
+ expect(fired?.updated_at).toBe(fired?.resolvedAt);
194
+ await Promise.all([executeSchedule(waiter), reconcileDeferredTaskWaits(resumed.id)]);
195
+ expect(await children(waiter.id)).toHaveLength(1);
196
+ expect(await waitState(waiter.id)).toEqual(fired);
197
+ }
198
+ });
199
+
200
+ test("event-driven deferrals retarget through a chain without refreshing the waiter's ceiling", async () => {
201
+ const { producer, schedule } = await fixture("task.failed");
202
+ let current = producer;
203
+ for (let step = 0; step < 2; step++) {
204
+ const dependency = await activeTask();
205
+ const deferred = await defer(current.id, dependency.id);
206
+ expect(deferred.structuredContent.success).toBe(true);
207
+ await reconcileDeferredTaskWaits(current.id);
208
+ expect(await waitState(schedule.id)).toMatchObject({ taskId: current.id, status: "pending" });
209
+ await completeTask(dependency.id, "dependency ready");
210
+ await reconcileDeferredTaskWaits(dependency.id);
211
+ current = (await children(deferred.structuredContent.scheduleId!))[0]!;
212
+ expect(await waitState(schedule.id)).toMatchObject({ taskId: current.id, status: "pending" });
213
+ expect((await getScheduledTaskById(schedule.id))?.nextRunAt).toBe(schedule.nextRunAt);
214
+ }
215
+
216
+ await failTask(current.id, "real failure");
217
+ // Reopen to verify the new watched task is durable across restarts.
218
+ closeDb();
219
+ initDb(path);
220
+ await initDeferredTaskWaits();
221
+ const child = await awaitChild(schedule.id);
222
+ expect(child.task).toContain(`Wake-up cause: task.failed for task ${current.id}`);
223
+ expect(await children(schedule.id)).toHaveLength(1);
224
+ });
225
+
226
+ test("the original ceiling still fires during the defer gap and fired waits never retarget", async () => {
227
+ const { producer, schedule } = await fixture();
228
+ const deferred = await defer(producer.id, producer.id, "settled", { wakeOn: undefined });
229
+ const successor = (await getScheduledTaskById(deferred.structuredContent.scheduleId!))!;
230
+ await reconcileDeferredTaskWaits(producer.id);
231
+ expect(await children(schedule.id)).toHaveLength(0);
232
+ expect((await getScheduledTaskById(schedule.id))?.nextRunAt).toBe(schedule.nextRunAt);
233
+ await executeSchedule(schedule);
234
+ const fired = await waitState(schedule.id);
235
+ expect(fired).toMatchObject({ taskId: producer.id, firedBy: "ceiling", status: "fired" });
236
+ expect(fired?.updated_at).toBe(fired?.resolvedAt);
237
+ await executeSchedule(successor);
238
+ const resumed = (await children(successor.id))[0]!;
239
+ await completeTask(resumed.id, "late result");
240
+ await reconcileDeferredTaskWaits();
241
+ expect(await waitState(schedule.id)).toEqual(fired);
242
+ expect(await children(schedule.id)).toHaveLength(1);
243
+ });
244
+
245
+ test.each([
246
+ "disabled",
247
+ "deleted",
248
+ ])("a successor schedule that is %s during the gap releases the parent's completion", async (action) => {
249
+ const { producer, schedule } = await fixture();
250
+ const deferred = await defer(producer.id, producer.id, "settled", { wakeOn: undefined });
251
+ const successorId = deferred.structuredContent.scheduleId!;
252
+ await reconcileDeferredTaskWaits(producer.id);
253
+ expect(await children(schedule.id)).toHaveLength(0);
254
+ if (action === "disabled") await updateScheduledTask(successorId, { enabled: false });
255
+ else await deleteScheduledTask(successorId);
256
+ await reconcileDeferredTaskWaits();
257
+ expect(await children(schedule.id)).toHaveLength(1);
258
+ expect(await waitState(schedule.id)).toMatchObject({
259
+ taskId: producer.id,
260
+ status: "fired",
261
+ firedBy: "task.completed",
262
+ });
263
+ });
264
+
265
+ test("schema output stays verbatim while a task event wakes the continuation", async () => {
266
+ const parent = await createTaskExtended("structured deferral", {
267
+ agentId,
268
+ outputSchema: { type: "string" },
269
+ });
270
+ await startTask(parent.id);
271
+ const producer = await activeTask();
272
+
273
+ for (const output of [undefined, "not JSON", "42"]) {
274
+ const result = await defer(parent.id, producer.id, "settled", { output });
275
+ expect(result.structuredContent.success).toBe(false);
276
+ expect((await getTaskById(parent.id))?.status).toBe("in_progress");
277
+ expect(
278
+ await getDbClient().query("SELECT id FROM scheduled_tasks WHERE parentTaskId = ?", [
279
+ parent.id,
280
+ ]),
281
+ ).toHaveLength(0);
282
+ }
283
+
284
+ const output = ' "deploy pending"\n';
285
+ const result = await defer(parent.id, producer.id, "settled", { output });
286
+ expect(result.structuredContent.success).toBe(true);
287
+ expect((await getTaskById(parent.id))?.output).toBe(output);
288
+ const scheduleId = result.structuredContent.scheduleId!;
289
+ const logs = (await getLogsByTaskId(parent.id)).filter(
290
+ (log) => log.eventType === "task_progress",
291
+ );
292
+ expect(logs).toHaveLength(1);
293
+ expect(logs[0]!.newValue).toContain("Work submitted");
294
+ expect(logs[0]!.newValue).toContain(scheduleId);
295
+
296
+ await completeTask(producer.id, "ready");
297
+ await reconcileDeferredTaskWaits(producer.id);
298
+ const found = await children(scheduleId);
299
+ expect(found).toHaveLength(1);
300
+ expect(found[0]!.parentTaskId).toBe(parent.id);
301
+ expect(found[0]!.task).toContain("Wake-up cause: task.completed");
302
+ expect((await getTaskById(parent.id))?.output).toBe(output);
303
+ });
304
+
305
+ test("event fires first: completion bus wakes the normal child and consumes the ceiling", async () => {
306
+ await initDeferredTaskWaits();
307
+ const { parent, producer, schedule } = await fixture("task.completed");
308
+ await completeTask(producer.id, "ready");
309
+ const child = await awaitChild(schedule.id);
310
+ expect(child.parentTaskId).toBe(parent.id);
311
+ expect(child.agentId).toBe(agentId);
312
+ expect(child.modelTier).toBe("smart");
313
+ expect(child.model).toBeUndefined();
314
+ expect(child.priority).toBe(70);
315
+ expect(child.taskType).toBe("deferred");
316
+ expect(child.task).toContain(`Resume task ${parent.id}: Check the producer result`);
317
+ expect(child.task).toContain("- verify output");
318
+ expect(child.task).toContain(`Wake-up cause: task.completed for task ${producer.id}`);
319
+ expect((await getScheduledTaskById(schedule.id))?.enabled).toBe(false);
320
+ await executeSchedule(schedule); // stale poller snapshot after event dispatch
321
+ await reconcileDeferredTaskWaits();
322
+ expect(await children(schedule.id)).toHaveLength(1);
323
+ });
324
+
325
+ test("ceiling fires first: later task failure cannot create a second child", async () => {
326
+ await initDeferredTaskWaits();
327
+ const { producer, schedule } = await fixture();
328
+ await executeSchedule(schedule);
329
+ await failTask(producer.id, "failed later");
330
+ await reconcileDeferredTaskWaits();
331
+ const found = await children(schedule.id);
332
+ expect(found).toHaveLength(1);
333
+ expect(found[0]?.task).toContain("Wake-up cause: ceiling expired");
334
+ });
335
+
336
+ test("both race: event and ceiling dispatch commit exactly one child", async () => {
337
+ const { producer, schedule } = await fixture();
338
+ await completeTask(producer.id, "done");
339
+ await Promise.all([
340
+ reconcileDeferredTaskWaits(producer.id),
341
+ executeSchedule(schedule),
342
+ dispatchScheduleTarget(schedule),
343
+ reconcileDeferredTaskWaits(producer.id),
344
+ ]);
345
+ const found = await children(schedule.id);
346
+ expect(found).toHaveLength(1);
347
+ const wait = await getDbClient().get<{ childTaskId: string; status: string }>(
348
+ "SELECT childTaskId, status FROM deferred_task_waits WHERE scheduleId = ?",
349
+ [schedule.id],
350
+ );
351
+ expect(wait?.childTaskId).toBe(found[0]!.id);
352
+ expect(wait?.status).toBe("fired");
353
+ expect((await getScheduledTaskById(schedule.id))?.nextRunAt).toBeUndefined();
354
+ });
355
+
356
+ test("restart mid-wait: persisted waits regain listeners and catch events missed while stopped", async () => {
357
+ const live = await fixture("task.failed");
358
+ const missed = await fixture("settled");
359
+ stopDeferredTaskWaits();
360
+ await completeTask(missed.producer.id, "completed while listener was down");
361
+ closeDb();
362
+ initDb(path);
363
+ await initDeferredTaskWaits();
364
+ expect(await children(missed.schedule.id)).toHaveLength(1);
365
+ expect(await children(live.schedule.id)).toHaveLength(0);
366
+ await failTask(live.producer.id, "failed after restart");
367
+ expect((await awaitChild(live.schedule.id)).task).toContain("Wake-up cause: task.failed");
368
+ await initDeferredTaskWaits();
369
+ expect(await children(missed.schedule.id)).toHaveLength(1);
370
+ expect(await children(live.schedule.id)).toHaveLength(1);
371
+ });
372
+
373
+ test("failed child creation rolls back the claim and preserves the ceiling for retry", async () => {
374
+ const { producer, schedule } = await fixture();
375
+ await completeTask(producer.id, "done");
376
+ await updateScheduledTask(schedule.id, { taskTemplate: "" });
377
+ await expect(dispatchDeferredTaskWait(schedule.id, "task.completed")).rejects.toThrow(
378
+ "no taskTemplate",
379
+ );
380
+ expect(
381
+ (
382
+ await getDbClient().get<{ status: string }>(
383
+ "SELECT status FROM deferred_task_waits WHERE scheduleId = ?",
384
+ [schedule.id],
385
+ )
386
+ )?.status,
387
+ ).toBe("pending");
388
+ expect((await getScheduledTaskById(schedule.id))?.enabled).toBe(true);
389
+ expect(await children(schedule.id)).toHaveLength(0);
390
+ await updateScheduledTask(schedule.id, { taskTemplate: schedule.taskTemplate });
391
+ await executeSchedule(schedule);
392
+ expect(await children(schedule.id)).toHaveLength(1);
393
+ });
394
+
395
+ test("unmatched task or event does not wake; cancelled producer still has a ceiling", async () => {
396
+ await initDeferredTaskWaits();
397
+ const { producer, schedule } = await fixture("task.completed");
398
+ const other = await activeTask();
399
+ await completeTask(other.id, "unrelated");
400
+ await failTask(producer.id, "wrong event");
401
+ await reconcileDeferredTaskWaits();
402
+ expect(await children(schedule.id)).toHaveLength(0);
403
+ const cancelled = await fixture();
404
+ await cancelTask(cancelled.producer.id, "cancelled");
405
+ await reconcileDeferredTaskWaits();
406
+ expect(await children(cancelled.schedule.id)).toHaveLength(0);
407
+ await executeSchedule(cancelled.schedule);
408
+ expect(await children(cancelled.schedule.id)).toHaveLength(1);
409
+ await executeSchedule(schedule);
410
+ });
411
+
412
+ test("already terminal, missing, and self-watched tasks reject without completing the parent", async () => {
413
+ const parent = await activeTask();
414
+ const producer = await activeTask();
415
+ await completeTask(producer.id, "already done");
416
+ for (const [id, message] of [
417
+ [producer.id, "already completed"],
418
+ [crypto.randomUUID(), "not found"],
419
+ [parent.id, "Cannot wake on"],
420
+ ]) {
421
+ const result = await defer(parent.id, id!);
422
+ expect(result.structuredContent.success).toBe(false);
423
+ expect(result.structuredContent.message).toContain(message!);
424
+ }
425
+ expect((await getTaskById(parent.id))?.status).toBe("in_progress");
426
+ expect(
427
+ await getDbClient().query("SELECT id FROM scheduled_tasks WHERE parentTaskId = ?", [
428
+ parent.id,
429
+ ]),
430
+ ).toHaveLength(0);
431
+ });
432
+
433
+ test("wakeOn requires exactly one ceiling and schema permits only task event sources", async () => {
434
+ const parent = await activeTask();
435
+ const producer = await activeTask();
436
+ for (const extra of [
437
+ { delayMs: undefined },
438
+ { runAt: new Date(Date.now() + 60000).toISOString() },
439
+ ]) {
440
+ expect(
441
+ (await defer(parent.id, producer.id, "settled", extra)).structuredContent.success,
442
+ ).toBe(false);
443
+ }
444
+ const base = { taskId: parent.id, summary: "done", note: "wait", delayMs: 60000 };
445
+ expect(
446
+ tool.inputSchema.safeParse({
447
+ ...base,
448
+ wakeOn: { taskId: producer.id, event: "checks.completed" },
449
+ }).success,
450
+ ).toBe(false);
451
+ expect(
452
+ tool.inputSchema.safeParse({ ...base, wakeOn: { taskId: producer.id, event: "settled" } })
453
+ .success,
454
+ ).toBe(true);
455
+ });
456
+ });
@@ -453,6 +453,7 @@ describe("Heartbeat — reroute-decision fallback (DES-523)", () => {
453
453
  {
454
454
  task: "Resume the crashed work on B",
455
455
  agentId: agentB.id,
456
+ routingReason: "reroute_fault",
456
457
  taskType: "resume",
457
458
  parentTaskId: original.id,
458
459
  allowDuplicate: true,