@desplega.ai/agent-swarm 1.113.0 → 1.115.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 (237) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-q4n7cz6e.js → actions-17ya5wde.js} +8 -9
  3. package/dist/{app-es4nzc71.js → app-4f718yk0.js} +6 -6
  4. package/dist/{assistant-0x8ey0vs.js → assistant-ah7csnjz.js} +19 -18
  5. package/dist/{boot-reembed-69agkhv6.js → boot-reembed-4jpxhb25.js} +6 -6
  6. package/dist/{boot-reembed-p0ny05t8.js → boot-reembed-qata34xa.js} +5 -5
  7. package/dist/{boot-scrub-logs-yybrkmvr.js → boot-scrub-logs-tv48nn3z.js} +4 -4
  8. package/dist/{claude-adapter-mma9a1yr.js → claude-adapter-3csn5bks.js} +3 -3
  9. package/dist/{claude-managed-adapter-pv2nbqy4.js → claude-managed-adapter-kysmkz89.js} +2 -2
  10. package/dist/cli-0pygq9r1.js +429 -0
  11. package/dist/{cli-hsetkkd3.js → cli-12vb0xg6.js} +33 -9
  12. package/dist/{cli-ncfgsqbd.js → cli-15y7qhzp.js} +262 -33
  13. package/dist/{cli-7ga43spr.js → cli-1yjrbg1f.js} +1 -1
  14. package/dist/{cli-36ymmpmp.js → cli-3samkyap.js} +2 -2
  15. package/dist/cli-52fg7s7x.js +269 -0
  16. package/dist/{cli-hjhvekf4.js → cli-65ekje01.js} +11 -8
  17. package/dist/{cli-h3k622d0.js → cli-6aq8xhvm.js} +90 -3
  18. package/dist/{cli-f3qa069v.js → cli-7xje5amz.js} +1 -1
  19. package/dist/cli-86fdnerz.js +405 -0
  20. package/dist/{cli-9cy6nc5m.js → cli-8fjwym53.js} +1 -1
  21. package/dist/{cli-c3frqf65.js → cli-b23axgz4.js} +3 -3
  22. package/dist/{cli-a0t7vapf.js → cli-bc89my0z.js} +1 -1
  23. package/dist/{cli-bnzbn8j8.js → cli-beea4a3y.js} +73 -24
  24. package/dist/{cli-q21d49ac.js → cli-bw80ck94.js} +1 -1
  25. package/dist/{cli-pwc5e83c.js → cli-cchksf5q.js} +12 -7
  26. package/dist/{cli-30bbaveh.js → cli-cmw9rzy5.js} +2 -2
  27. package/dist/{cli-y6mrptcn.js → cli-cnwssmsr.js} +1 -1
  28. package/dist/{cli-r5g6ngtn.js → cli-dcca6j8m.js} +46 -5
  29. package/dist/{cli-tkmm5nf9.js → cli-e61fs2hp.js} +59 -2
  30. package/dist/{cli-6wscj9z0.js → cli-e7306swv.js} +14 -14
  31. package/dist/{cli-dcfyyh3t.js → cli-ftyf58xj.js} +2 -2
  32. package/dist/{cli-4xyj9jtq.js → cli-hrhye2rm.js} +1062 -478
  33. package/dist/cli-jkhwmmgg.js +136 -0
  34. package/dist/{cli-y4ycrnjc.js → cli-k3y4z3h5.js} +1 -1
  35. package/dist/{cli-6xd0bvf8.js → cli-ma04faw5.js} +1 -1
  36. package/dist/{cli-2fc97xm5.js → cli-mnbgv3w7.js} +4 -4
  37. package/dist/{cli-trmapn9k.js → cli-pet3aev6.js} +95 -10
  38. package/dist/{cli-z46pnksz.js → cli-q1rx7g0d.js} +175 -28
  39. package/dist/{cli-qwgtacd6.js → cli-r9f6ngrf.js} +1244 -336
  40. package/dist/{cli-gtkqc144.js → cli-shvaxw27.js} +13 -7
  41. package/dist/{cli-ywez6yps.js → cli-sq7f72d6.js} +1 -1
  42. package/dist/cli-t0vqevnm.js +393 -0
  43. package/dist/{cli-tbw13sx8.js → cli-tece6zgm.js} +1 -1
  44. package/dist/{cli-bgvskydy.js → cli-tffvrh6j.js} +1 -1
  45. package/dist/cli-wh91zby6.js +187222 -0
  46. package/dist/{cli-anrj584m.js → cli-xz9aq0rf.js} +1 -1
  47. package/dist/{cli-mq580e06.js → cli-zsrdty74.js} +18 -18
  48. package/dist/cli.js +32 -15
  49. package/dist/{codex-adapter-5cmy858w.js → codex-adapter-de3w0nch.js} +10 -10
  50. package/dist/{codex-session-runner-46q8mrba.js → codex-session-runner-bnw8cysk.js} +10 -10
  51. package/dist/{commands-z5dbwta3.js → commands-hch4e2k1.js} +4 -4
  52. package/dist/{db-bbgahh4z.js → db-p3h2j06x.js} +14 -4
  53. package/dist/{handlers-6b44317z.js → handlers-9er4zx9q.js} +17 -31
  54. package/dist/{hook-sbp5fmps.js → hook-4axs756z.js} +7 -7
  55. package/dist/{http-2xz43gz3.js → http-809fvtz2.js} +1934 -886
  56. package/dist/{index-wz1ea15p.js → index-2dgfj9av.js} +3 -3
  57. package/dist/{index-nhat35em.js → index-47em3d2n.js} +14 -11
  58. package/dist/{index-5ghxn8s6.js → index-9fqaktjr.js} +12 -9
  59. package/dist/{index-0gzmqj4k.js → index-9ht8gr56.js} +13 -10
  60. package/dist/{index-vfedgdw6.js → index-rm85cfdx.js} +368 -40
  61. package/dist/{index-ykvs43z4.js → index-t2snazez.js} +4 -4
  62. package/dist/{keepalive-62gfj91d.js → keepalive-3cps6e3e.js} +7 -6
  63. package/dist/{lead-ag0akn0v.js → lead-fkk95wgq.js} +36 -32
  64. package/dist/{maintenance-xxrrzk5x.js → maintenance-fweqh0yj.js} +6 -6
  65. package/dist/{mistral-conversations-26yrnbf1.js → mistral-conversations-7gqnz2wt.js} +7 -7
  66. package/dist/oauth-refresh-sweep-sycqtbph.js +114 -0
  67. package/dist/{onboard-64zyfcbs.js → onboard-cy0rkgvx.js} +2 -2
  68. package/dist/{opencode-adapter-mqsxr170.js → opencode-adapter-p6bxnbm4.js} +2 -2
  69. package/dist/{otel-zjd9zaxs.js → otel-g299k41b.js} +2 -2
  70. package/dist/{otel-impl-b86qseaa.js → otel-impl-wbns8hf7.js} +1 -1
  71. package/dist/{pi-mono-adapter-kxjxpfgy.js → pi-mono-adapter-05sck1ds.js} +17 -109
  72. package/dist/{pricing-refresh-cgtkffpa.js → pricing-refresh-0hvazmkf.js} +6 -6
  73. package/dist/rbac-roles-kfesjy9y.js +28 -0
  74. package/dist/rbac-roles-mwcwhbxc.js +283 -0
  75. package/dist/{seed-pricing-1r1y1xkq.js → seed-pricing-h2kdq4cq.js} +5 -5
  76. package/dist/{setup-mkcgkjf7.js → setup-8506q97w.js} +2 -2
  77. package/dist/{worker-351sze63.js → worker-4r5c6zyb.js} +36 -32
  78. package/openapi.json +1327 -20
  79. package/package.json +3 -3
  80. package/src/agentmail/handlers.ts +16 -6
  81. package/src/agentmail/templates.ts +25 -5
  82. package/src/be/audit-user.ts +28 -4
  83. package/src/be/db-queries/oauth.ts +42 -0
  84. package/src/be/db.ts +314 -28
  85. package/src/be/identity.ts +79 -0
  86. package/src/be/mcp-proxy.ts +132 -0
  87. package/src/be/migrations/109_rbac_roles.sql +79 -0
  88. package/src/be/migrations/111_oauth_credential_bindings.sql +6 -0
  89. package/src/be/migrations/112_script_connections_graphql.sql +96 -0
  90. package/src/be/migrations/113_task_routing_affinity.sql +9 -0
  91. package/src/be/migrations/114_backfill_gpt_5_6_pricing.sql +15 -0
  92. package/src/be/modelsdev-cache.json +135 -0
  93. package/src/be/oauth-credential-bindings.ts +35 -0
  94. package/src/be/oauth-refresh-sweep.ts +159 -0
  95. package/src/be/rbac-audit.ts +52 -13
  96. package/src/be/rbac-roles.ts +424 -0
  97. package/src/be/script-connections.ts +459 -40
  98. package/src/be/script-credential-broker.ts +29 -3
  99. package/src/be/scripts/typecheck.ts +51 -7
  100. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +325 -24
  101. package/src/be/seed-scripts/catalog/catalog-report.ts +325 -24
  102. package/src/be/users.ts +24 -0
  103. package/src/cli.tsx +17 -0
  104. package/src/github/handlers.ts +10 -9
  105. package/src/github/templates.ts +45 -9
  106. package/src/gitlab/handlers.ts +20 -8
  107. package/src/gitlab/templates.ts +18 -6
  108. package/src/heartbeat/heartbeat.ts +143 -21
  109. package/src/http/all-routes.ts +3 -0
  110. package/src/http/approval-requests.ts +12 -0
  111. package/src/http/config.ts +44 -10
  112. package/src/http/core.ts +30 -3
  113. package/src/http/favorites.ts +1 -5
  114. package/src/http/index.ts +25 -1
  115. package/src/http/mcp-oauth.ts +2 -0
  116. package/src/http/mcp-servers.ts +44 -3
  117. package/src/http/oauth-generic.ts +105 -0
  118. package/src/http/poll.ts +20 -3
  119. package/src/http/script-connection-proxy.ts +116 -0
  120. package/src/http/script-connections.ts +1657 -0
  121. package/src/http/scripts.ts +27 -7
  122. package/src/http/skills.ts +11 -0
  123. package/src/http/tasks.ts +27 -13
  124. package/src/http/x.ts +6 -2
  125. package/src/integrations/kapso/inbound.ts +23 -6
  126. package/src/jira/sync.ts +111 -17
  127. package/src/jira/templates.ts +10 -2
  128. package/src/mcp-client/http-client.ts +194 -0
  129. package/src/oauth/app-validation.ts +16 -0
  130. package/src/oauth/ensure-token.ts +69 -14
  131. package/src/oauth/mcp-wrapper.ts +15 -0
  132. package/src/oauth/wrapper.ts +48 -20
  133. package/src/prompts/session-templates.ts +3 -3
  134. package/src/providers/codex-models.ts +100 -30
  135. package/src/providers/modelsdev-reasoning.json +51 -0
  136. package/src/providers/pi-mono-mcp-client.ts +2 -133
  137. package/src/providers/reasoning-effort.ts +11 -5
  138. package/src/rbac/admission.ts +68 -0
  139. package/src/rbac/index.ts +2 -0
  140. package/src/rbac/legacy-policy.ts +12 -0
  141. package/src/rbac/permissions.ts +48 -0
  142. package/src/scheduler/scheduler.ts +9 -1
  143. package/src/scripts-runtime/api-client.ts +151 -19
  144. package/src/scripts-runtime/api-types.ts +38 -6
  145. package/src/scripts-runtime/credential-broker/default-bindings.ts +1 -0
  146. package/src/scripts-runtime/credential-broker/types.ts +8 -0
  147. package/src/scripts-runtime/ctx.ts +11 -1
  148. package/src/scripts-runtime/eval-harness.ts +5 -1
  149. package/src/scripts-runtime/executors/types.ts +2 -1
  150. package/src/scripts-runtime/loader.ts +3 -1
  151. package/src/scripts-runtime/mcp-client.ts +87 -0
  152. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  153. package/src/scripts-runtime/types/stdlib.d.ts +6 -0
  154. package/src/scripts-runtime/types/swarm-sdk.d.ts +19 -0
  155. package/src/server-user.ts +114 -59
  156. package/src/server.ts +12 -0
  157. package/src/slack/assistant.ts +14 -6
  158. package/src/slack/enrich.ts +17 -0
  159. package/src/slack/handlers.ts +8 -22
  160. package/src/slack/responses.ts +91 -1
  161. package/src/slack/thread-buffer.ts +6 -3
  162. package/src/slack/watcher.ts +9 -6
  163. package/src/tasks/worker-follow-up.ts +100 -3
  164. package/src/tests/agentmail-handlers.test.ts +32 -3
  165. package/src/tests/approval-requests.test.ts +61 -0
  166. package/src/tests/audit-user.test.ts +47 -1
  167. package/src/tests/codex-adapter.test.ts +46 -23
  168. package/src/tests/credential-broker.test.ts +5 -2
  169. package/src/tests/delete-page-tool.test.ts +197 -0
  170. package/src/tests/github-handlers.test.ts +28 -2
  171. package/src/tests/gitlab-handlers.test.ts +125 -0
  172. package/src/tests/heartbeat.test.ts +48 -3
  173. package/src/tests/identity.test.ts +117 -0
  174. package/src/tests/jira-sync.test.ts +99 -1
  175. package/src/tests/kapso-inbound.test.ts +7 -0
  176. package/src/tests/mcp-tools-user.test.ts +60 -6
  177. package/src/tests/model-control.test.ts +8 -2
  178. package/src/tests/oauth-credential-bindings.test.ts +490 -0
  179. package/src/tests/oauth-refresh-sweep.test.ts +160 -0
  180. package/src/tests/pool-affinity.test.ts +401 -0
  181. package/src/tests/prompt-template-remaining.test.ts +2 -2
  182. package/src/tests/rbac-admission-e2e.test.ts +386 -0
  183. package/src/tests/rbac-admission.test.ts +492 -0
  184. package/src/tests/rbac-e2e-helpers.ts +3 -0
  185. package/src/tests/rbac-engine.test.ts +50 -0
  186. package/src/tests/rbac-mcp-admission-e2e.test.ts +362 -0
  187. package/src/tests/rbac-roles.test.ts +328 -0
  188. package/src/tests/reasoning-effort.test.ts +15 -2
  189. package/src/tests/schedule-target-type.test.ts +50 -0
  190. package/src/tests/scheduled-tasks.test.ts +40 -0
  191. package/src/tests/script-apis-mcp.test.ts +37 -13
  192. package/src/tests/script-connections-http.test.ts +1158 -0
  193. package/src/tests/script-connections-mcp.test.ts +894 -0
  194. package/src/tests/script-connections.test.ts +754 -6
  195. package/src/tests/scripts-external-api.test.ts +49 -1
  196. package/src/tests/scripts-runtime-secret-egress.test.ts +1 -0
  197. package/src/tests/scripts-runtime.test.ts +291 -0
  198. package/src/tests/seed-scripts.test.ts +36 -0
  199. package/src/tests/slack-identity-resolution.test.ts +37 -3
  200. package/src/tests/slack-inline-output.test.ts +65 -0
  201. package/src/tests/slack-thread-buffer.test.ts +28 -0
  202. package/src/tests/tool-annotations.test.ts +1 -0
  203. package/src/tests/user-token-rest-auth.test.ts +140 -1
  204. package/src/tests/workflow-swarm-script.test.ts +44 -0
  205. package/src/tests/workflow-triggers-v2.test.ts +300 -1
  206. package/src/tools/credential-bindings/tool.ts +277 -18
  207. package/src/tools/delete-page.ts +145 -0
  208. package/src/tools/mcp-servers/mcp-server-delete.ts +7 -4
  209. package/src/tools/request-human-input.ts +2 -0
  210. package/src/tools/resolve-user.ts +77 -27
  211. package/src/tools/script-apis.ts +62 -3
  212. package/src/tools/script-connections/tool.ts +275 -31
  213. package/src/tools/send-task.ts +15 -1
  214. package/src/tools/task-action.ts +34 -2
  215. package/src/tools/templates.ts +53 -1
  216. package/src/tools/tool-config.ts +2 -1
  217. package/src/tools/utils.ts +2 -0
  218. package/src/tools/workflows/create-workflow.ts +8 -1
  219. package/src/tools/workflows/update-workflow.ts +11 -2
  220. package/src/types.ts +90 -11
  221. package/src/utils/scoped-resource.ts +25 -0
  222. package/src/workflows/executors/human-in-the-loop.ts +1 -0
  223. package/src/workflows/executors/swarm-script.ts +7 -1
  224. package/src/workflows/triggers.ts +146 -18
  225. package/templates/skills/swarm-scripts/content.md +33 -8
  226. package/dist/cli-bgef578c.js +0 -771
  227. package/dist/cli-c9mtm09b.js +0 -43
  228. package/dist/cli-ceewqajw.js +0 -1698
  229. package/dist/{anthropic-messages-8nc6r2dw.js → anthropic-messages-kq3pjhbp.js} +6 -6
  230. package/dist/{azure-openai-responses-6yfzw3qw.js → azure-openai-responses-gm1ejtmn.js} +7 -7
  231. package/dist/{cli-vbraamxr.js → cli-6b4wwmm8.js} +9 -9
  232. package/dist/{cli-2r7s8jhp.js → cli-sznberje.js} +3 -3
  233. package/dist/{google-generative-ai-vfq6ymz9.js → google-generative-ai-hyrra2jm.js} +3 -3
  234. package/dist/{google-vertex-mj5y5rfj.js → google-vertex-8bs2pzgn.js} +3 -3
  235. package/dist/{openai-codex-responses-8h6kvfv8.js → openai-codex-responses-3nkvje79.js} +4 -4
  236. package/dist/{openai-completions-x6kjnqjd.js → openai-completions-0asftkwg.js} +11 -11
  237. package/dist/{openai-responses-7jwyz3ds.js → openai-responses-pmatek45.js} +10 -10
@@ -0,0 +1,401 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ assignUnassignedTaskPending,
5
+ claimTask,
6
+ closeDb,
7
+ createAgent,
8
+ createTaskExtended,
9
+ getAgentById,
10
+ getDb,
11
+ getTaskById,
12
+ getUnassignedTaskIdsForAgent,
13
+ initDb,
14
+ isAgentEligibleForTask,
15
+ updateAgentProfile,
16
+ } from "../be/db";
17
+ import { codeLevelTriage } from "../heartbeat/heartbeat";
18
+ import type { RoutingAffinity } from "../types";
19
+
20
+ const TEST_DB_PATH = "./test-pool-affinity.sqlite";
21
+
22
+ describe("Pool Affinity", () => {
23
+ beforeAll(async () => {
24
+ try {
25
+ await unlink(TEST_DB_PATH);
26
+ } catch {
27
+ // File doesn't exist
28
+ }
29
+ closeDb();
30
+ initDb(TEST_DB_PATH);
31
+ });
32
+
33
+ afterAll(async () => {
34
+ closeDb();
35
+ try {
36
+ await unlink(TEST_DB_PATH);
37
+ await unlink(`${TEST_DB_PATH}-wal`);
38
+ await unlink(`${TEST_DB_PATH}-shm`);
39
+ } catch {
40
+ // Files may not exist
41
+ }
42
+ });
43
+
44
+ // Clean up between tests to avoid interference
45
+ beforeEach(() => {
46
+ getDb().run("DELETE FROM agent_tasks");
47
+ getDb().run("DELETE FROM agents");
48
+ getDb().run("DELETE FROM agent_log");
49
+ });
50
+
51
+ function affinity(overrides: Partial<RoutingAffinity>): RoutingAffinity {
52
+ return { capabilities: [], ...overrides };
53
+ }
54
+
55
+ // ==========================================================================
56
+ // isAgentEligibleForTask matrix
57
+ // ==========================================================================
58
+
59
+ describe("isAgentEligibleForTask", () => {
60
+ test("untagged task (no routingAffinity) is eligible for anyone", () => {
61
+ const agent = createAgent({ name: "no-role-agent", isLead: false, status: "idle" });
62
+ const task = createTaskExtended("Untagged task");
63
+ expect(isAgentEligibleForTask(agent, task)).toBe(true);
64
+ });
65
+
66
+ test("sourceAgentId bypass: own work is eligible even with a mismatched role", () => {
67
+ const owner = createAgent({ name: "owner", isLead: false, status: "idle" });
68
+ updateAgentProfile(owner.id, { role: "researcher" });
69
+ const ownerAgent = getAgentById(owner.id)!;
70
+ const task = createTaskExtended("Own work", {
71
+ routingAffinity: affinity({ sourceAgentId: owner.id, role: "coder" }),
72
+ });
73
+ expect(isAgentEligibleForTask(ownerAgent, task)).toBe(true);
74
+ });
75
+
76
+ test("exact role match is eligible", () => {
77
+ const agent = createAgent({ name: "coder-1", isLead: false, status: "idle" });
78
+ updateAgentProfile(agent.id, { role: "coder" });
79
+ const coderAgent = getAgentById(agent.id)!;
80
+ const task = createTaskExtended("Coding task", {
81
+ routingAffinity: affinity({ role: "coder" }),
82
+ });
83
+ expect(isAgentEligibleForTask(coderAgent, task)).toBe(true);
84
+ });
85
+
86
+ test("role mismatch is ineligible", () => {
87
+ const agent = createAgent({ name: "researcher-1", isLead: false, status: "idle" });
88
+ updateAgentProfile(agent.id, { role: "researcher" });
89
+ const researcherAgent = getAgentById(agent.id)!;
90
+ const task = createTaskExtended("Coding task", {
91
+ routingAffinity: affinity({ role: "coder" }),
92
+ });
93
+ expect(isAgentEligibleForTask(researcherAgent, task)).toBe(false);
94
+ });
95
+
96
+ test("missing role on the agent is ineligible — no fail-open", () => {
97
+ const agent = createAgent({ name: "no-role-agent-2", isLead: false, status: "idle" });
98
+ const task = createTaskExtended("Coding task", {
99
+ routingAffinity: affinity({ role: "coder" }),
100
+ });
101
+ expect(isAgentEligibleForTask(agent, task)).toBe(false);
102
+ });
103
+
104
+ test("missing role on the task's affinity (capabilities-only) is ineligible for a non-owner — no fail-open", () => {
105
+ const agent = createAgent({ name: "coder-2", isLead: false, status: "idle" });
106
+ updateAgentProfile(agent.id, { role: "coder" });
107
+ const coderAgent = getAgentById(agent.id)!;
108
+ const task = createTaskExtended("Capability-only task", {
109
+ routingAffinity: affinity({ capabilities: ["datadog"] }),
110
+ });
111
+ expect(isAgentEligibleForTask(coderAgent, task)).toBe(false);
112
+ });
113
+
114
+ test("capability subset: missing a required capability is ineligible", () => {
115
+ const agent = createAgent({ name: "coder-3", isLead: false, status: "idle" });
116
+ updateAgentProfile(agent.id, { role: "coder", capabilities: ["typescript"] });
117
+ const coderAgent = getAgentById(agent.id)!;
118
+ const task = createTaskExtended("Needs datadog", {
119
+ routingAffinity: affinity({ role: "coder", capabilities: ["datadog"] }),
120
+ });
121
+ expect(isAgentEligibleForTask(coderAgent, task)).toBe(false);
122
+ });
123
+
124
+ test("capability subset: a superset of required capabilities is eligible", () => {
125
+ const agent = createAgent({ name: "coder-4", isLead: false, status: "idle" });
126
+ updateAgentProfile(agent.id, { role: "coder", capabilities: ["typescript", "datadog"] });
127
+ const coderAgent = getAgentById(agent.id)!;
128
+ const task = createTaskExtended("Needs datadog", {
129
+ routingAffinity: affinity({ role: "coder", capabilities: ["datadog"] }),
130
+ });
131
+ expect(isAgentEligibleForTask(coderAgent, task)).toBe(true);
132
+ });
133
+
134
+ test("kill-switch: POOL_AFFINITY_ENFORCEMENT=0 makes every task eligible", () => {
135
+ const previous = process.env.POOL_AFFINITY_ENFORCEMENT;
136
+ process.env.POOL_AFFINITY_ENFORCEMENT = "0";
137
+ try {
138
+ const agent = createAgent({ name: "researcher-2", isLead: false, status: "idle" });
139
+ updateAgentProfile(agent.id, { role: "researcher" });
140
+ const researcherAgent = getAgentById(agent.id)!;
141
+ const task = createTaskExtended("Coding task", {
142
+ routingAffinity: affinity({ role: "coder" }),
143
+ });
144
+ expect(isAgentEligibleForTask(researcherAgent, task)).toBe(true);
145
+ } finally {
146
+ if (previous === undefined) {
147
+ delete process.env.POOL_AFFINITY_ENFORCEMENT;
148
+ } else {
149
+ process.env.POOL_AFFINITY_ENFORCEMENT = previous;
150
+ }
151
+ }
152
+ });
153
+ });
154
+
155
+ // ==========================================================================
156
+ // claimTask eligibility gate
157
+ // ==========================================================================
158
+
159
+ describe("claimTask", () => {
160
+ test("rejects an ineligible agent and logs task_claim_rejected_affinity", () => {
161
+ const researcher = createAgent({ name: "researcher-3", isLead: false, status: "idle" });
162
+ updateAgentProfile(researcher.id, { role: "researcher" });
163
+ const task = createTaskExtended("Coding task", {
164
+ routingAffinity: affinity({ role: "coder" }),
165
+ });
166
+
167
+ const result = claimTask(task.id, researcher.id);
168
+ expect(result).toBeNull();
169
+
170
+ const stillUnassigned = getTaskById(task.id);
171
+ expect(stillUnassigned?.status).toBe("unassigned");
172
+
173
+ const log = getDb()
174
+ .query("SELECT eventType FROM agent_log WHERE taskId = ? ORDER BY createdAt DESC LIMIT 1")
175
+ .get(task.id) as { eventType: string } | null;
176
+ expect(log?.eventType).toBe("task_claim_rejected_affinity");
177
+ });
178
+
179
+ test("an eligible agent can claim after an ineligible agent was rejected", () => {
180
+ const researcher = createAgent({ name: "researcher-4", isLead: false, status: "idle" });
181
+ updateAgentProfile(researcher.id, { role: "researcher" });
182
+ const coder = createAgent({ name: "coder-5", isLead: false, status: "idle" });
183
+ updateAgentProfile(coder.id, { role: "coder" });
184
+ const task = createTaskExtended("Coding task", {
185
+ routingAffinity: affinity({ role: "coder" }),
186
+ });
187
+
188
+ expect(claimTask(task.id, researcher.id)).toBeNull();
189
+
190
+ const claimed = claimTask(task.id, coder.id);
191
+ expect(claimed).not.toBeNull();
192
+ expect(claimed?.status).toBe("in_progress");
193
+ expect(claimed?.agentId).toBe(coder.id);
194
+ });
195
+
196
+ test("only one of two eligible agents wins a race for the same task", () => {
197
+ const coderA = createAgent({ name: "coder-6a", isLead: false, status: "idle" });
198
+ updateAgentProfile(coderA.id, { role: "coder" });
199
+ const coderB = createAgent({ name: "coder-6b", isLead: false, status: "idle" });
200
+ updateAgentProfile(coderB.id, { role: "coder" });
201
+ const task = createTaskExtended("Coding task", {
202
+ routingAffinity: affinity({ role: "coder" }),
203
+ });
204
+
205
+ const first = claimTask(task.id, coderA.id);
206
+ const second = claimTask(task.id, coderB.id);
207
+
208
+ expect([first, second].filter((r) => r !== null).length).toBe(1);
209
+ });
210
+
211
+ test("own sourceAgentId can reclaim its own affinity-tagged task", () => {
212
+ const agent = createAgent({ name: "owner-2", isLead: false, status: "idle" });
213
+ updateAgentProfile(agent.id, { role: "researcher" });
214
+ const task = createTaskExtended("Own resumed work", {
215
+ routingAffinity: affinity({ sourceAgentId: agent.id, role: "coder" }),
216
+ });
217
+
218
+ const claimed = claimTask(task.id, agent.id);
219
+ expect(claimed).not.toBeNull();
220
+ expect(claimed?.agentId).toBe(agent.id);
221
+ });
222
+ });
223
+
224
+ // ==========================================================================
225
+ // assignUnassignedTaskPending eligibility gate
226
+ // ==========================================================================
227
+
228
+ describe("assignUnassignedTaskPending", () => {
229
+ test("rejects an ineligible agent (defense in depth)", () => {
230
+ const researcher = createAgent({ name: "researcher-5", isLead: false, status: "idle" });
231
+ updateAgentProfile(researcher.id, { role: "researcher" });
232
+ const task = createTaskExtended("Coding task", {
233
+ routingAffinity: affinity({ role: "coder" }),
234
+ });
235
+
236
+ const result = assignUnassignedTaskPending(task.id, researcher.id);
237
+ expect(result).toBeNull();
238
+ expect(getTaskById(task.id)?.status).toBe("unassigned");
239
+ });
240
+
241
+ test("assigns an eligible agent", () => {
242
+ const coder = createAgent({ name: "coder-7", isLead: false, status: "idle" });
243
+ updateAgentProfile(coder.id, { role: "coder" });
244
+ const task = createTaskExtended("Coding task", {
245
+ routingAffinity: affinity({ role: "coder" }),
246
+ });
247
+
248
+ const result = assignUnassignedTaskPending(task.id, coder.id);
249
+ expect(result?.status).toBe("pending");
250
+ expect(result?.agentId).toBe(coder.id);
251
+ });
252
+ });
253
+
254
+ // ==========================================================================
255
+ // getUnassignedTaskIdsForAgent ordering + filtering
256
+ // ==========================================================================
257
+
258
+ describe("getUnassignedTaskIdsForAgent", () => {
259
+ test("filters out ineligible tasks and preserves priority/creation ordering", () => {
260
+ const coder = createAgent({ name: "coder-8", isLead: false, status: "idle" });
261
+ updateAgentProfile(coder.id, { role: "coder" });
262
+
263
+ const researchTask = createTaskExtended("Research task", {
264
+ routingAffinity: affinity({ role: "researcher" }),
265
+ priority: 90,
266
+ });
267
+ const lowPriorityCoderTask = createTaskExtended("Low priority coding task", {
268
+ routingAffinity: affinity({ role: "coder" }),
269
+ priority: 10,
270
+ });
271
+ const highPriorityCoderTask = createTaskExtended("High priority coding task", {
272
+ routingAffinity: affinity({ role: "coder" }),
273
+ priority: 80,
274
+ });
275
+ const untaggedTask = createTaskExtended("Untagged task", { priority: 50 });
276
+
277
+ const ids = getUnassignedTaskIdsForAgent(coder.id, 10);
278
+
279
+ expect(ids).not.toContain(researchTask.id);
280
+ expect(ids).toContain(lowPriorityCoderTask.id);
281
+ expect(ids).toContain(highPriorityCoderTask.id);
282
+ expect(ids).toContain(untaggedTask.id);
283
+
284
+ // Priority DESC ordering preserved among eligible candidates.
285
+ const highIdx = ids.indexOf(highPriorityCoderTask.id);
286
+ const untaggedIdx = ids.indexOf(untaggedTask.id);
287
+ const lowIdx = ids.indexOf(lowPriorityCoderTask.id);
288
+ expect(highIdx).toBeLessThan(untaggedIdx);
289
+ expect(untaggedIdx).toBeLessThan(lowIdx);
290
+ });
291
+
292
+ test("returns an empty list for an unknown agent", () => {
293
+ createTaskExtended("Untagged task");
294
+ expect(getUnassignedTaskIdsForAgent("00000000-0000-0000-0000-000000000000", 10)).toEqual([]);
295
+ });
296
+
297
+ test("paginates past a wall of ineligible affinity tasks larger than the old fixed scan window", () => {
298
+ // PR #954 review: the old implementation fetched a single fixed window
299
+ // (max(limit * 5, 25) = 50 rows for limit=10) and filtered in JS, so an
300
+ // eligible task sorted past row 50 was invisible no matter how many
301
+ // times this was called. This seeds 55 high-priority ineligible tasks
302
+ // ahead of one low-priority eligible task — more than the old window —
303
+ // to prove the scan now pages through rather than stopping at row 50.
304
+ const coder = createAgent({ name: "coder-9-pagination", isLead: false, status: "idle" });
305
+ updateAgentProfile(coder.id, { role: "coder" });
306
+
307
+ for (let i = 0; i < 55; i++) {
308
+ createTaskExtended(`Ineligible research task ${i}`, {
309
+ routingAffinity: affinity({ role: "researcher" }),
310
+ priority: 100,
311
+ });
312
+ }
313
+ const eligibleTask = createTaskExtended("Eligible coder task buried behind the wall", {
314
+ routingAffinity: affinity({ role: "coder" }),
315
+ priority: 1,
316
+ });
317
+
318
+ const ids = getUnassignedTaskIdsForAgent(coder.id, 10);
319
+
320
+ expect(ids).toContain(eligibleTask.id);
321
+ });
322
+ });
323
+
324
+ // ==========================================================================
325
+ // autoAssignPoolTasks (via codeLevelTriage) — per-task eligibility filtering
326
+ // ==========================================================================
327
+
328
+ describe("autoAssignPoolTasks eligibility", () => {
329
+ test("skips an ineligible idle worker and leaves the task queued", async () => {
330
+ const researcher = createAgent({ name: "idle-researcher", isLead: false, status: "idle" });
331
+ updateAgentProfile(researcher.id, { role: "researcher" });
332
+ const task = createTaskExtended("Coding task", {
333
+ routingAffinity: affinity({ role: "coder" }),
334
+ });
335
+
336
+ const findings = await codeLevelTriage();
337
+
338
+ expect(findings.autoAssigned.length).toBe(0);
339
+ expect(getTaskById(task.id)?.status).toBe("unassigned");
340
+ });
341
+
342
+ test("assigns to the eligible worker, skipping an ineligible one that sorts first", async () => {
343
+ const researcher = createAgent({ name: "idle-researcher-2", isLead: false, status: "idle" });
344
+ updateAgentProfile(researcher.id, { role: "researcher" });
345
+ const coder = createAgent({ name: "idle-coder", isLead: false, status: "idle" });
346
+ updateAgentProfile(coder.id, { role: "coder" });
347
+ const task = createTaskExtended("Coding task", {
348
+ routingAffinity: affinity({ role: "coder" }),
349
+ });
350
+
351
+ const findings = await codeLevelTriage();
352
+
353
+ expect(findings.autoAssigned.length).toBe(1);
354
+ expect(findings.autoAssigned[0]!.agentId).toBe(coder.id);
355
+ expect(getTaskById(task.id)?.agentId).toBe(coder.id);
356
+ });
357
+
358
+ test("untagged tasks are unaffected — assigned to any idle worker", async () => {
359
+ const worker = createAgent({ name: "idle-any", isLead: false, status: "idle" });
360
+ const task = createTaskExtended("Untagged pool task");
361
+
362
+ const findings = await codeLevelTriage();
363
+
364
+ expect(findings.autoAssigned.length).toBe(1);
365
+ expect(findings.autoAssigned[0]!.agentId).toBe(worker.id);
366
+ expect(getTaskById(task.id)?.agentId).toBe(worker.id);
367
+ });
368
+
369
+ test("paginates past a wall of ineligible affinity tasks larger than the old fixed sweep window", async () => {
370
+ // PR #954 review: the old implementation fetched only
371
+ // getUnassignedPoolTasks(MAX_AUTO_ASSIGN_PER_SWEEP) — a single bounded
372
+ // window (default 5) — so a high-priority run of affinity-tagged tasks
373
+ // for another role could hide all eligible work behind it forever,
374
+ // across every sweep, since the same ineligible head-of-line rows were
375
+ // re-fetched every time. This seeds 55 ineligible high-priority tasks
376
+ // (more than the default POOL_SCAN_BATCH_SIZE of 50) ahead of one
377
+ // low-priority eligible task, to prove the scan now pages through the
378
+ // pool rather than stopping at the first window.
379
+ const coder = createAgent({ name: "idle-coder-pagination", isLead: false, status: "idle" });
380
+ updateAgentProfile(coder.id, { role: "coder" });
381
+
382
+ for (let i = 0; i < 55; i++) {
383
+ createTaskExtended(`Ineligible research task ${i}`, {
384
+ routingAffinity: affinity({ role: "researcher" }),
385
+ priority: 100,
386
+ });
387
+ }
388
+ const eligibleTask = createTaskExtended("Eligible coder task buried behind the wall", {
389
+ routingAffinity: affinity({ role: "coder" }),
390
+ priority: 1,
391
+ });
392
+
393
+ const findings = await codeLevelTriage();
394
+
395
+ expect(findings.autoAssigned.length).toBe(1);
396
+ expect(findings.autoAssigned[0]!.taskId).toBe(eligibleTask.id);
397
+ expect(findings.autoAssigned[0]!.agentId).toBe(coder.id);
398
+ expect(getTaskById(eligibleTask.id)?.agentId).toBe(coder.id);
399
+ });
400
+ });
401
+ });
@@ -155,7 +155,7 @@ describe("GitLab — backward compatibility", () => {
155
155
  const expected = `[GitLab MR #5] Add feature
156
156
 
157
157
  Repo: group/project
158
- Author: @dev1
158
+ Author: dev1
159
159
  Branch: feature-branch \u2192 main
160
160
  URL: https://gitlab.com/group/project/-/merge_requests/5
161
161
 
@@ -206,7 +206,7 @@ The CI pipeline has failed. Please investigate and fix the issues.
206
206
 
207
207
  expect(result.skipped).toBe(false);
208
208
 
209
- const expected = `[GitLab Comment on MR #5] @dev1 mentioned bot
209
+ const expected = `[GitLab Comment on MR #5] dev1 mentioned bot
210
210
 
211
211
  Repo: group/project
212
212
  URL: https://gitlab.com/group/project/-/merge_requests/5